Compare commits

16 Commits

Author SHA1 Message Date
liuyuanchuang
18597ba7fa feat: add log for export error 2026-03-12 11:43:26 +08:00
2fafcb9bfd Merge pull request 'feature/google_oath' (#5) from feature/google_oath into master
Reviewed-on: #5
2026-03-06 14:07:42 +08:00
liuyuanchuang
b5d177910c feat: add email in userinfo 2026-03-06 14:02:04 +08:00
liuyuanchuang
7df6587fd6 chore: update docker compose 2026-03-06 11:22:41 +08:00
liuyuanchuang
94988790f8 feat: update callback url 2026-03-06 11:10:44 +08:00
liuyuanchuang
45dcef5702 feat: add proxy 2026-03-06 11:03:41 +08:00
liuyuanchuang
ed7232e5c0 feat google oauth 2026-03-06 10:28:56 +08:00
liuyuanchuang
8852ee5a3a Merge branch 'test' 2026-02-13 18:04:26 +08:00
liuyuanchuang
a7b73b0928 Merge branch 'master' of https://code.texpixel.com/YogeLiu/doc_ai_backed 2026-02-13 18:04:21 +08:00
liuyuanchuang
e35f3ed684 fix: update bucket 2026-02-12 19:45:29 +08:00
liuyuanchuang
aed09d4341 feat: update bucket 2026-02-12 19:41:53 +08:00
liuyuanchuang
a0cf063ff9 Merge branch 'master' into test 2026-02-12 19:40:51 +08:00
liuyuanchuang
323b712c18 fix: rm skd file 2026-02-12 19:40:23 +08:00
6786d174a6 Merge pull request 'feat: add mml' (#4) from test into master
Reviewed-on: #4
2026-02-05 13:48:14 +08:00
liuyuanchuang
de6b5d3960 Merge branch 'master' into test 2026-02-05 10:44:39 +08:00
liuyuanchuang
81c2767423 feat: add mml from backend 2026-02-05 10:44:11 +08:00
19 changed files with 350 additions and 918 deletions

View File

@@ -38,15 +38,20 @@ func SetupRouter(engine *gin.RouterGroup) {
ossRouter.POST("/file/upload", endpoint.UploadFile) ossRouter.POST("/file/upload", endpoint.UploadFile)
} }
userRouter := v1.Group("/user", common.GetAuthMiddleware()) userEndpoint := user.NewUserEndpoint()
userRouter := v1.Group("/user")
{ {
userEndpoint := user.NewUserEndpoint() userRouter.POST("/sms", userEndpoint.SendVerificationCode)
{ userRouter.POST("/register", userEndpoint.RegisterByEmail)
userRouter.POST("/sms", userEndpoint.SendVerificationCode) userRouter.POST("/login", userEndpoint.LoginByEmail)
userRouter.POST("/register", userEndpoint.RegisterByEmail) userRouter.GET("/oauth/google/url", userEndpoint.GetGoogleOAuthUrl)
userRouter.POST("/login", userEndpoint.LoginByEmail) userRouter.POST("/oauth/google/callback", userEndpoint.GoogleOAuthCallback)
userRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo) }
}
userAuthRouter := v1.Group("/user", common.GetAuthMiddleware())
{
userAuthRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo)
} }
// 数据埋点路由 // 数据埋点路由

View File

@@ -1,7 +1,9 @@
package user package user
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
"gitea.com/texpixel/document_ai/config" "gitea.com/texpixel/document_ai/config"
model "gitea.com/texpixel/document_ai/internal/model/user" model "gitea.com/texpixel/document_ai/internal/model/user"
@@ -98,15 +100,9 @@ func (h *UserEndpoint) GetUserInfo(ctx *gin.Context) {
return return
} }
status := 0
if user.ID > 0 {
status = 1
}
ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.UserInfoResponse{ ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.UserInfoResponse{
Username: user.Username, Username: user.Username,
Phone: user.Phone, Email: user.Email,
Status: status,
})) }))
} }
@@ -169,3 +165,69 @@ func (h *UserEndpoint) LoginByEmail(ctx *gin.Context) {
ExpiresAt: tokenResult.ExpiresAt, ExpiresAt: tokenResult.ExpiresAt,
})) }))
} }
func (h *UserEndpoint) GetGoogleOAuthUrl(ctx *gin.Context) {
req := model.GoogleAuthUrlRequest{}
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, common.CodeParamErrorMsg))
return
}
googleConfig := config.GlobalConfig.Google
if googleConfig.ClientID == "" {
log.Error(ctx, "func", "GetGoogleOAuthUrl", "msg", "Google OAuth not configured")
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg))
return
}
authURL := fmt.Sprintf(
"https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid%%20email%%20profile&state=%s",
url.QueryEscape(googleConfig.ClientID),
url.QueryEscape(req.RedirectURI),
url.QueryEscape(req.State),
)
ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.GoogleAuthUrlResponse{
AuthURL: authURL,
}))
}
func (h *UserEndpoint) GoogleOAuthCallback(ctx *gin.Context) {
req := model.GoogleOAuthCallbackRequest{}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, common.CodeParamErrorMsg))
return
}
googleConfig := config.GlobalConfig.Google
if googleConfig.ClientID == "" || googleConfig.ClientSecret == "" {
log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "Google OAuth not configured")
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg))
return
}
userInfo, err := h.userService.ExchangeGoogleCodeAndGetUserInfo(ctx, googleConfig.ClientID, googleConfig.ClientSecret, req.Code, req.RedirectURI)
if err != nil {
log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "exchange code failed", "error", err)
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg))
return
}
uid, err := h.userService.FindOrCreateGoogleUser(ctx, userInfo)
if err != nil {
log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "find or create user failed", "error", err)
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg))
return
}
tokenResult, err := jwt.CreateToken(jwt.User{UserId: uid})
if err != nil {
ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeUnauthorized, common.CodeUnauthorizedMsg))
return
}
ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.GoogleOAuthCallbackResponse{
Token: tokenResult.Token,
ExpiresAt: tokenResult.ExpiresAt,
}))
}

View File

@@ -6,21 +6,29 @@ import (
) )
type Config struct { type Config struct {
Log log.LogConfig `mapstructure:"log"` Log log.LogConfig `mapstructure:"log"`
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"` Redis RedisConfig `mapstructure:"redis"`
UploadDir string `mapstructure:"upload_dir"` UploadDir string `mapstructure:"upload_dir"`
Limit LimitConfig `mapstructure:"limit"` Limit LimitConfig `mapstructure:"limit"`
Aliyun AliyunConfig `mapstructure:"aliyun"` Aliyun AliyunConfig `mapstructure:"aliyun"`
Mathpix MathpixConfig `mapstructure:"mathpix"` Mathpix MathpixConfig `mapstructure:"mathpix"`
BaiduOCR BaiduOCRConfig `mapstructure:"baidu_ocr"` BaiduOCR BaiduOCRConfig `mapstructure:"baidu_ocr"`
Google GoogleOAuthConfig `mapstructure:"google"`
} }
type BaiduOCRConfig struct { type BaiduOCRConfig struct {
Token string `mapstructure:"token"` Token string `mapstructure:"token"`
} }
type GoogleOAuthConfig struct {
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
RedirectURI string `mapstructure:"redirect_uri"`
Proxy string `mapstructure:"proxy"`
}
type MathpixConfig struct { type MathpixConfig struct {
AppID string `mapstructure:"app_id"` AppID string `mapstructure:"app_id"`
AppKey string `mapstructure:"app_key"` AppKey string `mapstructure:"app_key"`

View File

@@ -4,8 +4,8 @@ server:
database: database:
driver: mysql driver: mysql
host: mysql host: localhost
port: 3306 port: 3006
username: root username: root
password: texpixel#pwd123! password: texpixel#pwd123!
dbname: doc_ai dbname: doc_ai
@@ -13,16 +13,16 @@ database:
max_open: 100 max_open: 100
redis: redis:
addr: redis:6379 addr: localhost:6079
password: yoge@123321! password: yoge@123321!
db: 0 db: 0
limit: limit:
formula_recognition: 3 formula_recognition: 3
log: log:
appName: document_ai appName: document_ai
level: info level: info
format: console # json, console format: console # json, console
outputPath: ./logs/app.log # 日志文件路径 outputPath: ./logs/app.log # 日志文件路径
maxSize: 2 # 单个日志文件最大尺寸单位MB maxSize: 2 # 单个日志文件最大尺寸单位MB
@@ -30,7 +30,6 @@ log:
maxBackups: 1 # 保留的旧日志文件最大数量 maxBackups: 1 # 保留的旧日志文件最大数量
compress: false # 是否压缩旧日志 compress: false # 是否压缩旧日志
aliyun: aliyun:
sms: sms:
access_key_id: "LTAI5tB9ur4ExCF4dYPq7hLz" access_key_id: "LTAI5tB9ur4ExCF4dYPq7hLz"
@@ -43,12 +42,17 @@ aliyun:
inner_endpoint: oss-cn-beijing-internal.aliyuncs.com inner_endpoint: oss-cn-beijing-internal.aliyuncs.com
access_key_id: LTAI5t8qXhow6NCdYDtu1saF access_key_id: LTAI5t8qXhow6NCdYDtu1saF
access_key_secret: qZ2SwYsNCEBckCVSOszH31yYwXU44A access_key_secret: qZ2SwYsNCEBckCVSOszH31yYwXU44A
bucket_name: texpixel-doc bucket_name: texpixel-doc1
mathpix: mathpix:
app_id: "ocr_eede6f_ea9b5c" app_id: "ocr_eede6f_ea9b5c"
app_key: "fb72d251e33ac85c929bfd4eec40d78368d08d82fb2ee1cffb04a8bb967d1db5" app_key: "fb72d251e33ac85c929bfd4eec40d78368d08d82fb2ee1cffb04a8bb967d1db5"
baidu_ocr: baidu_ocr:
token: "e3a47bd2438f1f38840c203fc5939d17a54482d1" token: "e3a47bd2438f1f38840c203fc5939d17a54482d1"
google:
client_id: "404402221037-nqdsk11bkpk5a7oh396mrg1ieh28u6q1.apps.googleusercontent.com"
client_secret: "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"
redirect_uri: "https://app.cloud.texpixel.com:10443/auth/google/callback"
proxy: "http://localhost:7890"

View File

@@ -42,12 +42,17 @@ aliyun:
inner_endpoint: oss-cn-beijing-internal.aliyuncs.com inner_endpoint: oss-cn-beijing-internal.aliyuncs.com
access_key_id: LTAI5t8qXhow6NCdYDtu1saF access_key_id: LTAI5t8qXhow6NCdYDtu1saF
access_key_secret: qZ2SwYsNCEBckCVSOszH31yYwXU44A access_key_secret: qZ2SwYsNCEBckCVSOszH31yYwXU44A
bucket_name: texpixel-doc bucket_name: texpixel-doc1
mathpix: mathpix:
app_id: "ocr_eede6f_ea9b5c" app_id: "ocr_eede6f_ea9b5c"
app_key: "fb72d251e33ac85c929bfd4eec40d78368d08d82fb2ee1cffb04a8bb967d1db5" app_key: "fb72d251e33ac85c929bfd4eec40d78368d08d82fb2ee1cffb04a8bb967d1db5"
baidu_ocr: baidu_ocr:
token: "e3a47bd2438f1f38840c203fc5939d17a54482d1" token: "e3a47bd2438f1f38840c203fc5939d17a54482d1"
google:
client_id: "404402221037-nqdsk11bkpk5a7oh396mrg1ieh28u6q1.apps.googleusercontent.com"
client_secret: "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"
redirect_uri: "https://texpixel.com/auth/google/callback"
proxy: "http://100.115.184.74:7890"

View File

@@ -6,7 +6,8 @@ ssh ubuntu << 'ENDSSH'
cd /home/yoge/Dev/doc_ai_backed cd /home/yoge/Dev/doc_ai_backed
git checkout test git checkout test
git pull origin test git pull origin test
docker compose down docker compose -f docker-compose.infra.yml up -d
docker compose down
docker image rm doc_ai_backed-doc_ai:latest docker image rm doc_ai_backed-doc_ai:latest
docker compose -f docker-compose.yml up -d docker compose up -d
ENDSSH ENDSSH

32
docker-compose.infra.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
mysql:
image: mysql:8.0
container_name: mysql
environment:
MYSQL_ROOT_PASSWORD: texpixel#pwd123!
MYSQL_DATABASE: doc_ai
MYSQL_USER: texpixel
MYSQL_PASSWORD: texpixel#pwd123!
ports:
- "3006:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-ptexpixel#pwd123!"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: always
redis:
image: redis:latest
container_name: redis
command: redis-server --requirepass "yoge@123321!"
ports:
- "6079:6379"
restart: always
volumes:
mysql_data:
driver: local

View File

@@ -2,58 +2,9 @@ services:
doc_ai: doc_ai:
build: . build: .
container_name: doc_ai container_name: doc_ai
ports: network_mode: host
- "8024:8024"
volumes: volumes:
- ./config:/app/config - ./config:/app/config
- ./logs:/app/logs - ./logs:/app/logs
networks:
- backend
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
command: ["-env", "dev"] command: ["-env", "dev"]
restart: always restart: always
mysql:
image: mysql:8.0
container_name: mysql
environment:
MYSQL_ROOT_PASSWORD: texpixel#pwd123!
MYSQL_DATABASE: doc_ai
MYSQL_USER: texpixel
MYSQL_PASSWORD: texpixel#pwd123!
ports:
- "3006:3306"
volumes:
- mysql_data:/var/lib/mysql
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-ptexpixel#pwd123!"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: always
redis:
image: redis:latest
container_name: redis
command: redis-server --requirepass "yoge@123321!"
ports:
- "6079:6379"
networks:
- backend
restart: always
volumes:
mysql_data:
# 持久化MySQL数据卷
driver: local
networks:
backend:
driver: bridge

View File

@@ -1,284 +0,0 @@
# 数据埋点 API 调用示例
## 基础信息
- **接口路径**: `/doc_ai/v1/analytics/track`
- **请求方法**: `POST`
- **Content-Type**: `application/json`
- **认证**: 可选Bearer Token
## 1. 基础埋点事件(最小参数)
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "button_click"
}'
```
## 2. 完整埋点事件(包含所有字段)
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"user_id": 12345,
"event_name": "formula_recognition_start",
"properties": {
"file_name": "math_formula.png",
"file_size": 102400,
"file_type": "image/png",
"upload_method": "drag_drop"
},
"device_info": {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"screen_width": 1920,
"screen_height": 1080,
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"platform": "MacIntel"
},
"meta_data": {
"task_id": "task_123456",
"timestamp": 1706342400000
}
}'
```
## 3. 页面浏览事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "page_view",
"properties": {
"page_url": "https://example.com/home",
"page_title": "首页",
"page_name": "home",
"referrer": "https://example.com/login"
},
"device_info": {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"screen_width": 1920,
"screen_height": 1080
}
}'
```
## 4. 任务相关事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"user_id": 12345,
"event_name": "task_create",
"properties": {
"task_type": "formula_recognition",
"file_name": "equation.png",
"file_size": 204800
},
"meta_data": {
"task_id": "task_789012"
}
}'
```
## 5. 任务完成事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "task_complete",
"properties": {
"duration_seconds": 5.2,
"success": true,
"result_type": "latex"
},
"meta_data": {
"task_id": "task_789012"
}
}'
```
## 6. 表单提交事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "form_submit",
"properties": {
"form_name": "user_registration",
"form_fields": ["email", "password", "phone"],
"success": true,
"validation_errors": 0
}
}'
```
## 7. 文件上传事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "file_upload",
"properties": {
"file_name": "document.pdf",
"file_size": 5242880,
"file_type": "application/pdf",
"upload_source": "drag_drop",
"upload_duration_ms": 1200
}
}'
```
## 8. 错误追踪事件
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{
"user_id": 12345,
"event_name": "error_occurred",
"properties": {
"error_type": "network_error",
"error_message": "Failed to fetch data",
"error_code": "NET_001",
"page_url": "https://example.com/tasks",
"user_action": "click_submit_button"
}
}'
```
## 9. 使用环境变量(推荐)
```bash
# 设置环境变量
export API_BASE_URL="http://localhost:8080"
export JWT_TOKEN="YOUR_JWT_TOKEN"
export USER_ID=12345
# 调用接口
curl -X POST ${API_BASE_URL}/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-d "{
\"user_id\": ${USER_ID},
\"event_name\": \"button_click\",
\"properties\": {
\"button_name\": \"submit\",
\"button_position\": \"bottom\"
}
}"
```
## 10. 使用 JSON 文件
创建 `event.json` 文件:
```json
{
"user_id": 12345,
"event_name": "custom_event",
"properties": {
"action": "click",
"element": "button",
"value": "submit"
},
"device_info": {
"user_agent": "Mozilla/5.0",
"screen_width": 1920,
"screen_height": 1080
},
"meta_data": {
"task_id": "task_123"
}
}
```
然后执行:
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d @event.json
```
## 11. 批量埋点接口
```bash
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track/batch \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"events": [
{
"user_id": 12345,
"event_name": "page_view",
"properties": {
"page_name": "home"
}
},
{
"user_id": 12345,
"event_name": "button_click",
"properties": {
"button_name": "start"
}
}
]
}'
```
## 响应示例
### 成功响应
```json
{
"code": 200,
"message": "success",
"data": null
}
```
### 错误响应
```json
{
"code": 400,
"message": "invalid request",
"data": null
}
```
## 注意事项
1. **user_id****event_name** 是必填字段
2. **properties**、**device_info**、**meta_data** 都是可选字段,类型为 JSON 对象
3. 如果提供了 Authorization headertoken 中的 user_id 会被设置到上下文中,但请求体中的 user_id 仍然需要提供
4. 建议在生产环境中始终使用 HTTPS
5. 批量接口最多支持 100 个事件
## 测试命令(本地开发)
```bash
# 最简单的测试
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{"user_id": 1, "event_name": "test_event"}'
# 查看响应详情
curl -X POST http://localhost:8080/doc_ai/v1/analytics/track \
-H "Content-Type: application/json" \
-d '{"user_id": 1, "event_name": "test_event"}' \
-v
```

View File

@@ -1,308 +0,0 @@
// Analytics SDK for Frontend
// 前端数据埋点 SDK
interface EventProperties {
[key: string]: any;
}
interface DeviceInfo {
user_agent?: string;
screen_width?: number;
screen_height?: number;
language?: string;
timezone?: string;
platform?: string;
}
interface MetaData {
task_id?: string | number;
[key: string]: any;
}
interface TrackEventParams {
event_name: string;
properties?: EventProperties;
device_info?: DeviceInfo;
meta_data?: MetaData;
}
interface AnalyticsConfig {
apiUrl: string;
token?: string;
userId?: number | string;
enableAutoTrack?: boolean;
debug?: boolean;
}
class Analytics {
private config: AnalyticsConfig;
private userId: number | string | null = null;
private eventQueue: TrackEventParams[] = [];
private isSending: boolean = false;
constructor(config: AnalyticsConfig) {
this.config = {
enableAutoTrack: true,
debug: false,
...config,
};
if (this.config.userId) {
this.userId = this.config.userId;
}
// 自动收集设备信息
if (this.config.enableAutoTrack) {
this.initAutoTrack();
}
}
/**
* 设置用户ID
*/
setUserId(userId: number | string) {
this.userId = userId;
}
/**
* 获取设备信息
*/
private getDeviceInfo(): DeviceInfo {
return {
user_agent: navigator.userAgent,
screen_width: window.screen.width,
screen_height: window.screen.height,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
platform: navigator.platform,
};
}
/**
* 记录单个事件
*/
async track(params: TrackEventParams): Promise<void> {
if (!this.userId) {
console.warn('Analytics: userId not set, event will not be tracked');
return;
}
const eventData = {
user_id: this.userId,
event_name: params.event_name,
properties: params.properties || {},
device_info: {
...this.getDeviceInfo(),
...params.device_info,
},
meta_data: {
timestamp: Date.now(),
...params.meta_data,
},
};
if (this.config.debug) {
console.log('Analytics Track:', eventData);
}
try {
const response = await fetch(`${this.config.apiUrl}/track`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.token && { Authorization: `Bearer ${this.config.token}` }),
},
body: JSON.stringify(eventData),
});
if (!response.ok) {
throw new Error(`Failed to track event: ${response.statusText}`);
}
if (this.config.debug) {
console.log('Analytics: Event tracked successfully');
}
} catch (error) {
console.error('Analytics: Failed to track event', error);
// 失败时加入队列,稍后重试
this.eventQueue.push(params);
}
}
/**
* 批量记录事件
*/
async trackBatch(events: TrackEventParams[]): Promise<void> {
if (!this.userId) {
console.warn('Analytics: userId not set, events will not be tracked');
return;
}
const batchData = {
events: events.map((params) => ({
user_id: this.userId,
event_name: params.event_name,
properties: params.properties || {},
device_info: {
...this.getDeviceInfo(),
...params.device_info,
},
meta_data: {
timestamp: Date.now(),
...params.meta_data,
},
})),
};
if (this.config.debug) {
console.log('Analytics Track Batch:', batchData);
}
try {
const response = await fetch(`${this.config.apiUrl}/track/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.token && { Authorization: `Bearer ${this.config.token}` }),
},
body: JSON.stringify(batchData),
});
if (!response.ok) {
throw new Error(`Failed to track batch events: ${response.statusText}`);
}
if (this.config.debug) {
console.log('Analytics: Batch events tracked successfully');
}
} catch (error) {
console.error('Analytics: Failed to track batch events', error);
}
}
/**
* 页面浏览事件
*/
trackPageView(pageName?: string) {
this.track({
event_name: 'page_view',
properties: {
page_url: window.location.href,
page_title: document.title,
page_name: pageName || document.title,
referrer: document.referrer,
},
});
}
/**
* 点击事件
*/
trackClick(elementName: string, properties?: EventProperties) {
this.track({
event_name: 'click',
properties: {
element_name: elementName,
page_url: window.location.href,
...properties,
},
});
}
/**
* 表单提交事件
*/
trackFormSubmit(formName: string, properties?: EventProperties) {
this.track({
event_name: 'form_submit',
properties: {
form_name: formName,
page_url: window.location.href,
...properties,
},
});
}
/**
* 任务相关事件
*/
trackTask(taskId: string | number, action: string, properties?: EventProperties) {
this.track({
event_name: `task_${action}`,
properties: {
action,
...properties,
},
meta_data: {
task_id: taskId,
},
});
}
/**
* 初始化自动埋点
*/
private initAutoTrack() {
// 页面加载完成时记录
if (document.readyState === 'complete') {
this.trackPageView();
} else {
window.addEventListener('load', () => this.trackPageView());
}
// 页面离开前发送队列中的事件
window.addEventListener('beforeunload', () => {
if (this.eventQueue.length > 0) {
this.flushQueue();
}
});
// 页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.hidden && this.eventQueue.length > 0) {
this.flushQueue();
}
});
}
/**
* 刷新队列中的事件
*/
private flushQueue() {
if (this.isSending || this.eventQueue.length === 0) {
return;
}
this.isSending = true;
const eventsToSend = [...this.eventQueue];
this.eventQueue = [];
this.trackBatch(eventsToSend).finally(() => {
this.isSending = false;
});
}
/**
* 手动刷新队列
*/
flush() {
this.flushQueue();
}
}
// 导出单例实例
let analyticsInstance: Analytics | null = null;
export function initAnalytics(config: AnalyticsConfig): Analytics {
analyticsInstance = new Analytics(config);
return analyticsInstance;
}
export function getAnalytics(): Analytics {
if (!analyticsInstance) {
throw new Error('Analytics not initialized. Call initAnalytics first.');
}
return analyticsInstance;
}
export default Analytics;

View File

@@ -1,217 +0,0 @@
// Analytics SDK 使用示例
import { initAnalytics, getAnalytics } from './analytics';
// 1. 初始化 SDK
const analytics = initAnalytics({
apiUrl: 'https://your-api-domain.com/doc_ai/v1/analytics',
token: 'your-auth-token', // 从登录后获取
userId: 12345, // 用户ID
enableAutoTrack: true, // 启用自动埋点(页面浏览等)
debug: true, // 开发环境下启用调试
});
// 2. 设置用户ID登录后
analytics.setUserId(12345);
// 3. 记录页面浏览
analytics.trackPageView('首页');
// 4. 记录点击事件
const handleButtonClick = () => {
analytics.trackClick('提交按钮', {
button_text: '提交',
button_position: 'bottom',
});
};
// 5. 记录表单提交
const handleFormSubmit = (formData: any) => {
analytics.trackFormSubmit('用户注册表单', {
form_fields: Object.keys(formData),
success: true,
});
};
// 6. 记录任务相关事件
const handleTaskCreate = (taskId: string) => {
analytics.trackTask(taskId, 'create', {
task_type: 'formula_recognition',
file_type: 'image/png',
});
};
const handleTaskComplete = (taskId: string) => {
analytics.trackTask(taskId, 'complete', {
duration_seconds: 5.2,
success: true,
});
};
// 7. 记录自定义事件
const handleFileUpload = (file: File) => {
analytics.track({
event_name: 'file_upload',
properties: {
file_name: file.name,
file_size: file.size,
file_type: file.type,
},
meta_data: {
upload_source: 'drag_drop',
},
});
};
// 8. 批量记录事件
const handleBatchActions = () => {
analytics.trackBatch([
{
event_name: 'button_click',
properties: { button_name: 'save' },
},
{
event_name: 'data_export',
properties: { format: 'pdf' },
},
]);
};
// 9. React 组件中使用
import React, { useEffect } from 'react';
function HomePage() {
useEffect(() => {
// 页面加载时记录
getAnalytics().trackPageView('首页');
}, []);
const handleClick = () => {
getAnalytics().trackClick('首页-开始按钮');
};
return (
<div>
<h1></h1>
<button onClick={handleClick}></button>
</div>
);
}
// 10. Vue 组件中使用
export default {
name: 'HomePage',
mounted() {
getAnalytics().trackPageView('首页');
},
methods: {
handleClick() {
getAnalytics().trackClick('首页-开始按钮');
},
},
};
// 11. 记录用户行为流程
class FormulaRecognitionFlow {
private analytics = getAnalytics();
private taskId: string | null = null;
// 开始识别流程
startRecognition(file: File) {
this.analytics.track({
event_name: 'formula_recognition_start',
properties: {
file_name: file.name,
file_size: file.size,
},
});
}
// 上传成功
uploadSuccess(taskId: string) {
this.taskId = taskId;
this.analytics.trackTask(taskId, 'upload_success', {
step: 'upload',
});
}
// 识别进行中
recognitionProcessing() {
if (this.taskId) {
this.analytics.trackTask(this.taskId, 'processing', {
step: 'recognition',
});
}
}
// 识别完成
recognitionComplete(result: any) {
if (this.taskId) {
this.analytics.trackTask(this.taskId, 'complete', {
step: 'complete',
has_result: !!result,
});
}
}
// 识别失败
recognitionFailed(error: string) {
if (this.taskId) {
this.analytics.trackTask(this.taskId, 'failed', {
step: 'error',
error_message: error,
});
}
}
// 查看结果
viewResult() {
if (this.taskId) {
this.analytics.trackTask(this.taskId, 'view_result', {
step: 'view',
});
}
}
// 导出结果
exportResult(format: string) {
if (this.taskId) {
this.analytics.trackTask(this.taskId, 'export', {
step: 'export',
export_format: format,
});
}
}
}
// 12. 错误追踪
window.addEventListener('error', (event) => {
getAnalytics().track({
event_name: 'javascript_error',
properties: {
error_message: event.message,
error_filename: event.filename,
error_line: event.lineno,
error_column: event.colno,
},
});
});
// 13. 性能追踪
window.addEventListener('load', () => {
const perfData = performance.timing;
const pageLoadTime = perfData.loadEventEnd - perfData.navigationStart;
getAnalytics().track({
event_name: 'page_performance',
properties: {
page_load_time: pageLoadTime,
dns_time: perfData.domainLookupEnd - perfData.domainLookupStart,
tcp_time: perfData.connectEnd - perfData.connectStart,
request_time: perfData.responseEnd - perfData.requestStart,
dom_parse_time: perfData.domComplete - perfData.domLoading,
},
});
});
export {};

View File

@@ -6,16 +6,13 @@ type CreateTaskResponse struct {
} }
type GetFormulaTaskResponse struct { type GetFormulaTaskResponse struct {
TaskNo string `json:"task_no"` TaskNo string `json:"task_no"`
Status int `json:"status"` Status int `json:"status"`
Count int `json:"count"` Count int `json:"count"`
Latex string `json:"latex"` Latex string `json:"latex"`
Markdown string `json:"markdown"` Markdown string `json:"markdown"`
MathML string `json:"mathml"` MathML string `json:"mathml"`
MathMLMW string `json:"mathml_mw"` MML string `json:"mml"`
ImageBlob string `json:"image_blob"`
DocxURL string `json:"docx_url"`
PDFURL string `json:"pdf_url"`
} }
// FormulaRecognitionResponse 公式识别服务返回的响应 // FormulaRecognitionResponse 公式识别服务返回的响应
@@ -25,7 +22,8 @@ type FormulaRecognitionResponse struct {
// ImageOCRResponse 图片OCR接口返回的响应 // ImageOCRResponse 图片OCR接口返回的响应
type ImageOCRResponse struct { type ImageOCRResponse struct {
Latex string `json:"latex"` // LaTeX 格式内容 Markdown string `json:"markdown"` // Markdown 格式内容
Markdown string `json:"markdown"` // Markdown 格式内容 Latex string `json:"latex"` // LaTeX 格式内容 (无公式时为空)
MathML string `json:"mathml"` // MathML 格式(无公式时为空) MathML string `json:"mathml"` // MathML 格式(无公式时为空)
MML string `json:"mml"` // MML 格式(无公式时为空)
} }

View File

@@ -24,10 +24,7 @@ type TaskListDTO struct {
Latex string `json:"latex"` Latex string `json:"latex"`
Markdown string `json:"markdown"` Markdown string `json:"markdown"`
MathML string `json:"mathml"` MathML string `json:"mathml"`
MathMLMW string `json:"mathml_mw"` MML string `json:"mml"`
ImageBlob string `json:"image_blob"`
DocxURL string `json:"docx_url"`
PDFURL string `json:"pdf_url"`
} }
type TaskListResponse struct { type TaskListResponse struct {

View File

@@ -20,8 +20,7 @@ type PhoneLoginResponse struct {
type UserInfoResponse struct { type UserInfoResponse struct {
Username string `json:"username"` Username string `json:"username"`
Phone string `json:"phone"` Email string `json:"email"`
Status int `json:"status"` // 0: not login, 1: login
} }
type EmailRegisterRequest struct { type EmailRegisterRequest struct {
@@ -43,3 +42,31 @@ type EmailLoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
} }
type GoogleAuthUrlRequest struct {
RedirectURI string `form:"redirect_uri" binding:"required"`
State string `form:"state" binding:"required"`
}
type GoogleAuthUrlResponse struct {
AuthURL string `json:"auth_url"`
}
type GoogleOAuthCallbackRequest struct {
Code string `json:"code" binding:"required"`
State string `json:"state" binding:"required"`
RedirectURI string `json:"redirect_uri" binding:"required"`
}
type GoogleOAuthCallbackResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
}
type GoogleUserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
}

View File

@@ -180,6 +180,7 @@ func (s *RecognitionService) GetFormualTask(ctx context.Context, taskNo string)
Latex: taskRet.Latex, Latex: taskRet.Latex,
Markdown: markdown, Markdown: markdown,
MathML: taskRet.MathML, MathML: taskRet.MathML,
MML: taskRet.MML,
Status: int(task.Status), Status: int(task.Status),
}, nil }, nil
} }
@@ -544,6 +545,7 @@ func (s *RecognitionService) processFormulaTask(ctx context.Context, taskID int6
Latex: ocrResp.Latex, Latex: ocrResp.Latex,
Markdown: ocrResp.Markdown, Markdown: ocrResp.Markdown,
MathML: ocrResp.MathML, MathML: ocrResp.MathML,
MML: ocrResp.MML,
}) })
if err != nil { if err != nil {
log.Error(ctx, "func", "processFormulaTask", "msg", "保存任务结果失败", "error", err) log.Error(ctx, "func", "processFormulaTask", "msg", "保存任务结果失败", "error", err)

View File

@@ -92,11 +92,13 @@ func (svc *TaskService) GetTaskList(ctx context.Context, req *task.TaskListReque
var latex string var latex string
var markdown string var markdown string
var mathML string var mathML string
var mml string
recognitionResult := recognitionResultMap[item.ID] recognitionResult := recognitionResultMap[item.ID]
if recognitionResult != nil { if recognitionResult != nil {
latex = recognitionResult.Latex latex = recognitionResult.Latex
markdown = recognitionResult.Markdown markdown = recognitionResult.Markdown
mathML = recognitionResult.MathML mathML = recognitionResult.MathML
mml = recognitionResult.MML
} }
originURL, err := oss.GetDownloadURL(ctx, item.FileURL) originURL, err := oss.GetDownloadURL(ctx, item.FileURL)
if err != nil { if err != nil {
@@ -106,6 +108,7 @@ func (svc *TaskService) GetTaskList(ctx context.Context, req *task.TaskListReque
Latex: latex, Latex: latex,
Markdown: markdown, Markdown: markdown,
MathML: mathML, MathML: mathML,
MML: mml,
TaskID: item.TaskUUID, TaskID: item.TaskUUID,
FileName: item.FileName, FileName: item.FileName,
Status: int(item.Status), Status: int(item.Status),
@@ -184,7 +187,8 @@ func (svc *TaskService) ExportTask(ctx context.Context, req *task.ExportTaskRequ
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Error(ctx, "func", "ExportTask", "msg", "http request failed", "status", resp.StatusCode) body, _ := io.ReadAll(resp.Body)
log.Error(ctx, "func", "ExportTask", "msg", "http request failed", "status", resp.StatusCode, "body", string(body))
return nil, "", fmt.Errorf("export service returned status: %d", resp.StatusCode) return nil, "", fmt.Errorf("export service returned status: %d", resp.StatusCode)
} }

View File

@@ -2,10 +2,15 @@ package service
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http"
"net/url"
"gitea.com/texpixel/document_ai/config"
model "gitea.com/texpixel/document_ai/internal/model/user"
"gitea.com/texpixel/document_ai/internal/storage/cache" "gitea.com/texpixel/document_ai/internal/storage/cache"
"gitea.com/texpixel/document_ai/internal/storage/dao" "gitea.com/texpixel/document_ai/internal/storage/dao"
"gitea.com/texpixel/document_ai/pkg/common" "gitea.com/texpixel/document_ai/pkg/common"
@@ -159,3 +164,126 @@ func (svc *UserService) LoginByEmail(ctx context.Context, email, password string
return user.ID, nil return user.ID, nil
} }
type googleTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
func (svc *UserService) googleHTTPClient() *http.Client {
if config.GlobalConfig.Google.Proxy == "" {
return &http.Client{}
}
proxyURL, err := url.Parse(config.GlobalConfig.Google.Proxy)
if err != nil {
return &http.Client{}
}
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
}
func (svc *UserService) ExchangeGoogleCodeAndGetUserInfo(ctx context.Context, clientID, clientSecret, code, redirectURI string) (*model.GoogleUserInfo, error) {
tokenURL := "https://oauth2.googleapis.com/token"
formData := url.Values{
"client_id": {clientID},
"client_secret": {clientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
}
client := svc.googleHTTPClient()
resp, err := client.PostForm(tokenURL, formData)
if err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "exchange code failed", "error", err)
return nil, err
}
defer resp.Body.Close()
var tokenResp googleTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "decode token response failed", "error", err)
return nil, err
}
if tokenResp.AccessToken == "" {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "no access token in response")
return nil, errors.New("no access token in response")
}
userInfo, err := svc.getGoogleUserInfo(ctx, tokenResp.AccessToken)
if err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "get user info failed", "error", err)
return nil, err
}
return &model.GoogleUserInfo{
ID: userInfo.ID,
Email: userInfo.Email,
Name: userInfo.Name,
}, nil
}
func (svc *UserService) getGoogleUserInfo(ctx context.Context, accessToken string) (*model.GoogleUserInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := svc.googleHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var userInfo model.GoogleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}
func (svc *UserService) FindOrCreateGoogleUser(ctx context.Context, userInfo *model.GoogleUserInfo) (uid int64, err error) {
existingUser, err := svc.userDao.GetByGoogleID(dao.DB.WithContext(ctx), userInfo.ID)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by google id error", "error", err)
return 0, err
}
if existingUser != nil {
return existingUser.ID, nil
}
existingUser, err = svc.userDao.GetByEmail(dao.DB.WithContext(ctx), userInfo.Email)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by email error", "error", err)
return 0, err
}
if existingUser != nil {
existingUser.GoogleID = userInfo.ID
err = svc.userDao.Update(dao.DB.WithContext(ctx), existingUser)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "update user google id error", "error", err)
return 0, err
}
return existingUser.ID, nil
}
user := &dao.User{
Email: userInfo.Email,
GoogleID: userInfo.ID,
Username: userInfo.Name,
}
err = svc.userDao.Create(dao.DB.WithContext(ctx), user)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "create user error", "error", err)
return 0, err
}
return user.ID, nil
}

View File

@@ -9,8 +9,9 @@ type RecognitionResult struct {
TaskID int64 `gorm:"column:task_id;bigint;not null;default:0;comment:任务ID" json:"task_id"` TaskID int64 `gorm:"column:task_id;bigint;not null;default:0;comment:任务ID" json:"task_id"`
TaskType TaskType `gorm:"column:task_type;varchar(16);not null;comment:任务类型;default:''" json:"task_type"` TaskType TaskType `gorm:"column:task_type;varchar(16);not null;comment:任务类型;default:''" json:"task_type"`
Latex string `json:"latex" gorm:"column:latex;type:text;not null;default:''"` Latex string `json:"latex" gorm:"column:latex;type:text;not null;default:''"`
Markdown string `json:"markdown" gorm:"column:markdown;type:text;not null;default:''"` // Mathpix Markdown 格式 Markdown string `json:"markdown" gorm:"column:markdown;type:text;not null;default:''"` // Markdown 格式
MathML string `json:"mathml" gorm:"column:mathml;type:text;not null;default:''"` // MathML 格式 MathML string `json:"mathml" gorm:"column:mathml;type:text;not null;default:''"` // MathML 格式
MML string `json:"mml" gorm:"column:mml;type:text;not null;default:''"` // MML 格式
} }
type RecognitionResultDao struct { type RecognitionResultDao struct {

View File

@@ -14,6 +14,7 @@ type User struct {
Password string `gorm:"column:password" json:"password"` Password string `gorm:"column:password" json:"password"`
WechatOpenID string `gorm:"column:wechat_open_id" json:"wechat_open_id"` WechatOpenID string `gorm:"column:wechat_open_id" json:"wechat_open_id"`
WechatUnionID string `gorm:"column:wechat_union_id" json:"wechat_union_id"` WechatUnionID string `gorm:"column:wechat_union_id" json:"wechat_union_id"`
GoogleID string `gorm:"column:google_id" json:"google_id"`
} }
func (u *User) TableName() string { func (u *User) TableName() string {
@@ -63,3 +64,18 @@ func (dao *UserDao) GetByEmail(tx *gorm.DB, email string) (*User, error) {
} }
return &user, nil return &user, nil
} }
func (dao *UserDao) GetByGoogleID(tx *gorm.DB, googleID string) (*User, error) {
var user User
if err := tx.Where("google_id = ?", googleID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (dao *UserDao) Update(tx *gorm.DB, user *User) error {
return tx.Save(user).Error
}