diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md new file mode 100644 index 0000000..f5b08bc --- /dev/null +++ b/.claude/skills/deploy/SKILL.md @@ -0,0 +1,40 @@ +--- +name: deploy +description: Use when deploying this project to dev or prod environments, or when asked to run, ship, release, or push to a server. +--- + +# Deploy + +## Environments + +### Dev (`/deploy dev`) +```bash +bash .claude/skills/deploy/deploy_dev.sh +``` +Builds and restarts the service on the dev server (ubuntu). + +### Prod (`/deploy prod`) +Prod deploy requires being on `master`. Steps: +1. Ensure all changes are committed and pushed to `master` +2. Run: +```bash +bash .claude/skills/deploy/deploy_prod.sh +``` + +`deploy_prod.sh` will: +- Pull latest code on ubuntu build host +- Build `linux/amd64` Docker image and push to registry +- SSH into ECS: stop old container, start new one with `-env=prod` + +## Quick Reference + +| Target | Command | Branch required | +|--------|---------|-----------------| +| Dev | `bash .claude/skills/deploy/deploy_dev.sh` | any | +| Prod | `bash .claude/skills/deploy/deploy_prod.sh` | `master` or `main` | + +## Common Mistakes + +- Running `deploy_prod.sh` on a feature branch → script guards against this (exits with error) +- Forgetting to merge/push before deploy → ubuntu build host pulls from remote, so local-only commits won't be included +- Prod logs go to `/app/logs/app.log` inside the container, not stdout — use `docker exec doc_ai tail -f /app/logs/app.log` on ECS to tail them diff --git a/deploy_dev.sh b/.claude/skills/deploy/deploy_dev.sh similarity index 100% rename from deploy_dev.sh rename to .claude/skills/deploy/deploy_dev.sh diff --git a/.claude/skills/deploy/deploy_prod.sh b/.claude/skills/deploy/deploy_prod.sh new file mode 100755 index 0000000..81c3ebf --- /dev/null +++ b/.claude/skills/deploy/deploy_prod.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +REGISTRY="crpi-8s2ierii2xan4klg.cn-beijing.personal.cr.aliyuncs.com/texpixel/doc_ai_backend" +BUILD_HOST="ubuntu" +BUILD_DIR="~/Dev/doc_ai_backed" + +# --- Guard: must be on main/master --- +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "${BRANCH}" != "main" && "${BRANCH}" != "master" ]]; then + echo "ERROR: must be on main or master branch (current: ${BRANCH})" + exit 1 +fi + +VERSION=$(git rev-parse --short HEAD) +IMAGE_VERSIONED="${REGISTRY}:${VERSION}" +IMAGE_LATEST="${REGISTRY}:latest" + +echo "==> [1/3] Pulling latest code on Ubuntu" +ssh ${BUILD_HOST} " + set -e + cd ${BUILD_DIR} + git fetch origin + git checkout master 2>/dev/null || git checkout main + git pull +" + +echo "==> [2/3] Building & pushing image on Ubuntu" +ssh ${BUILD_HOST} " + set -e + cd ${BUILD_DIR} + docker build --platform linux/amd64 \ + -t ${IMAGE_VERSIONED} \ + -t ${IMAGE_LATEST} \ + . + docker push ${IMAGE_VERSIONED} + docker push ${IMAGE_LATEST} + docker rmi ${IMAGE_VERSIONED} ${IMAGE_LATEST} 2>/dev/null || true +" + +echo "==> [3/3] Deploying on ECS" +ssh ecs " + set -e + echo '--- Pulling image' + docker pull ${IMAGE_VERSIONED} + + echo '--- Stopping old container' + docker stop doc_ai 2>/dev/null || true + docker rm doc_ai 2>/dev/null || true + + echo '--- Starting new container' + docker run -d \ + --name doc_ai \ + -p 8024:8024 \ + --restart unless-stopped \ + ${IMAGE_VERSIONED} \ + -env=prod + + echo '--- Removing old doc_ai images (keeping current)' + docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \ + | grep '^${REGISTRY}' \ + | grep -v ':${VERSION}' \ + | grep -v ':latest' \ + | awk '{print \$2}' \ + | xargs -r docker rmi || true +" + +echo "==> Done. Running version: ${VERSION}" diff --git a/.claude/skills/deploy/dev_deploy.sh b/.claude/skills/deploy/dev_deploy.sh new file mode 100755 index 0000000..3a72d3a --- /dev/null +++ b/.claude/skills/deploy/dev_deploy.sh @@ -0,0 +1,3 @@ +docker-compose down +docker image rm doc_ai_backed-doc_ai +docker-compose up -d \ No newline at end of file diff --git a/.claude/skills/deploy/speed_take.sh b/.claude/skills/deploy/speed_take.sh new file mode 100644 index 0000000..6c08f93 --- /dev/null +++ b/.claude/skills/deploy/speed_take.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +echo "=== Testing 401 Request Speed ===" +curl -X POST "https://api.mathpix.com/v3/text" \ + -H "Content-Type: application/json" \ + --data '{}' \ + -w "\n\n=== Timing ===\nHTTP Status: %{http_code}\nTotal: %{time_total}s\nConnect: %{time_connect}s\nDNS: %{time_namelookup}s\nTTFB: %{time_starttransfer}s\n" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1a1d885..a1c3b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ texpixel /vendor -dev_deploy.sh -speed_take.sh \ No newline at end of file +doc_ai +document_ai diff --git a/api/router.go b/api/router.go index f5c4263..4600ea1 100644 --- a/api/router.go +++ b/api/router.go @@ -4,6 +4,7 @@ import ( "gitea.com/texpixel/document_ai/api/v1/analytics" "gitea.com/texpixel/document_ai/api/v1/formula" "gitea.com/texpixel/document_ai/api/v1/oss" + "gitea.com/texpixel/document_ai/api/v1/pdf" "gitea.com/texpixel/document_ai/api/v1/task" "gitea.com/texpixel/document_ai/api/v1/user" "gitea.com/texpixel/document_ai/pkg/common" @@ -43,7 +44,7 @@ func SetupRouter(engine *gin.RouterGroup) { userRouter := v1.Group("/user") { userRouter.POST("/sms", userEndpoint.SendVerificationCode) - userRouter.POST("/email/code", userEndpoint.SendEmailVerificationCode) + userRouter.POST("/email/code", userEndpoint.SendEmailVerifyCode) userRouter.POST("/register", userEndpoint.RegisterByEmail) userRouter.POST("/login", userEndpoint.LoginByEmail) userRouter.GET("/oauth/google/url", userEndpoint.GetGoogleOAuthUrl) @@ -55,6 +56,13 @@ func SetupRouter(engine *gin.RouterGroup) { userAuthRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo) } + pdfRouter := v1.Group("/pdf", common.GetAuthMiddleware()) + { + endpoint := pdf.NewPDFEndpoint() + pdfRouter.POST("/recognition", endpoint.CreateTask) + pdfRouter.GET("/recognition/:task_no", endpoint.GetTaskStatus) + } + // 数据埋点路由 analyticsRouter := v1.Group("/analytics", common.GetAuthMiddleware()) { diff --git a/api/v1/oss/handler.go b/api/v1/oss/handler.go index a36e756..438c5c1 100644 --- a/api/v1/oss/handler.go +++ b/api/v1/oss/handler.go @@ -70,7 +70,7 @@ func (h *OSSEndpoint) GetSignatureURL(ctx *gin.Context) { ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, "invalid file name")) return } - if !utils.InArray(extend, []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"}) { + if !utils.InArray(extend, []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".pdf"}) { ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, "invalid file type")) return } diff --git a/api/v1/pdf/handler.go b/api/v1/pdf/handler.go new file mode 100644 index 0000000..33cdbe4 --- /dev/null +++ b/api/v1/pdf/handler.go @@ -0,0 +1,95 @@ +package pdf + +import ( + "net/http" + "path/filepath" + "strings" + + pdfmodel "gitea.com/texpixel/document_ai/internal/model/pdf" + "gitea.com/texpixel/document_ai/internal/service" + "gitea.com/texpixel/document_ai/pkg/common" + "gitea.com/texpixel/document_ai/pkg/constant" + + "github.com/gin-gonic/gin" +) + +type PDFEndpoint struct { + pdfService *service.PDFRecognitionService +} + +func NewPDFEndpoint() *PDFEndpoint { + return &PDFEndpoint{ + pdfService: service.NewPDFRecognitionService(), + } +} + +// CreateTask godoc +// @Summary Create a PDF recognition task +// @Description Create a new PDF recognition task (max 10 pages processed) +// @Tags PDF +// @Accept json +// @Produce json +// @Param request body pdfmodel.CreatePDFRecognitionRequest true "Create PDF task request" +// @Success 200 {object} common.Response{data=pdfmodel.CreatePDFTaskResponse} +// @Failure 400 {object} common.Response +// @Failure 500 {object} common.Response +// @Router /v1/pdf/recognition [post] +func (e *PDFEndpoint) CreateTask(c *gin.Context) { + var req pdfmodel.CreatePDFRecognitionRequest + if err := c.BindJSON(&req); err != nil { + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeParamError, "参数错误")) + return + } + req.UserID = c.GetInt64(constant.ContextUserID) + + if strings.ToLower(filepath.Ext(req.FileName)) != ".pdf" { + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeParamError, "仅支持PDF文件")) + return + } + + task, err := e.pdfService.CreatePDFTask(c, &req) + if err != nil { + if bizErr, ok := err.(*common.BusinessError); ok { + c.JSON(http.StatusOK, common.ErrorResponse(c, int(bizErr.Code), bizErr.Message)) + return + } + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeSystemError, "创建任务失败")) + return + } + + c.JSON(http.StatusOK, common.SuccessResponse(c, &pdfmodel.CreatePDFTaskResponse{ + TaskNo: task.TaskUUID, + Status: int(task.Status), + })) +} + +// GetTaskStatus godoc +// @Summary Get PDF recognition task status and results +// @Description Poll task status; pages field populated when status=2 (completed) +// @Tags PDF +// @Accept json +// @Produce json +// @Param task_no path string true "Task No" +// @Success 200 {object} common.Response{data=pdfmodel.GetPDFTaskResponse} +// @Failure 404 {object} common.Response +// @Failure 500 {object} common.Response +// @Router /v1/pdf/recognition/{task_no} [get] +func (e *PDFEndpoint) GetTaskStatus(c *gin.Context) { + var req pdfmodel.GetPDFTaskRequest + if err := c.ShouldBindUri(&req); err != nil { + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeParamError, "参数错误")) + return + } + + resp, err := e.pdfService.GetPDFTask(c, req.TaskNo, c.GetInt64(constant.ContextUserID)) + if err != nil { + if bizErr, ok := err.(*common.BusinessError); ok { + c.JSON(http.StatusOK, common.ErrorResponse(c, int(bizErr.Code), bizErr.Message)) + return + } + c.JSON(http.StatusOK, common.ErrorResponse(c, common.CodeSystemError, "查询任务失败")) + return + } + + c.JSON(http.StatusOK, common.SuccessResponse(c, resp)) +} diff --git a/api/v1/user/handler.go b/api/v1/user/handler.go index 2a690df..8148587 100644 --- a/api/v1/user/handler.go +++ b/api/v1/user/handler.go @@ -106,24 +106,24 @@ func (h *UserEndpoint) GetUserInfo(ctx *gin.Context) { })) } -func (h *UserEndpoint) SendEmailVerificationCode(ctx *gin.Context) { - req := model.EmailCodeSendRequest{} +func (h *UserEndpoint) SendEmailVerifyCode(ctx *gin.Context) { + req := model.EmailVerifyCodeRequest{} if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, common.CodeParamErrorMsg)) return } - if err := h.userService.SendEmailCode(ctx, req.Email); err != nil { + if err := h.userService.SendEmailVerifyCode(ctx, req.Email); err != nil { if bizErr, ok := err.(*common.BusinessError); ok { ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, int(bizErr.Code), bizErr.Message)) return } - log.Error(ctx, "func", "SendEmailVerificationCode", "msg", "发送邮箱验证码失败", "error", err) + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "发送邮件验证码失败", "error", err) ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg)) return } - ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.EmailCodeSendResponse{})) + ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.EmailVerifyCodeResponse{})) } func (h *UserEndpoint) RegisterByEmail(ctx *gin.Context) { @@ -133,7 +133,7 @@ func (h *UserEndpoint) RegisterByEmail(ctx *gin.Context) { return } - uid, err := h.userService.RegisterByEmail(ctx, req.Email, req.Password, req.Code) + uid, err := h.userService.RegisterByEmail(ctx, req.Email, req.Password, req.VerifyCode) if err != nil { if bizErr, ok := err.(*common.BusinessError); ok { ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, int(bizErr.Code), bizErr.Message)) diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index d90843f..823f0c0 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -195,12 +195,27 @@ func migrateData(testDB, prodDB *gorm.DB) error { mathml = *item.MathML } - newResult := dao.RecognitionResult{ - TaskID: newTask.ID, // 使用新任务的ID - TaskType: dao.TaskType(item.TaskType), + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ Latex: latex, Markdown: markdown, MathML: mathml, + }) + if err != nil { + log.Printf("[%d/%d] 序列化公式内容失败: task_id=%d, error=%v", i+1, len(tasksWithResults), newTask.ID, err) + tx.Rollback() + errorCount++ + continue + } + newResult := dao.RecognitionResult{ + TaskID: newTask.ID, // 使用新任务的ID + TaskType: dao.TaskType(item.TaskType), + Content: contentJSON, + } + if err := newResult.SetMetaData(dao.ResultMetaData{TotalNum: 1}); err != nil { + log.Printf("[%d/%d] 序列化MetaData失败: task_id=%d, error=%v", i+1, len(tasksWithResults), newTask.ID, err) + tx.Rollback() + errorCount++ + continue } // 保留原始时间戳 if item.ResultCreatedAt != nil { diff --git a/config/config_dev.yaml b/config/config_dev.yaml index b50d704..9cee0cc 100644 --- a/config/config_dev.yaml +++ b/config/config_dev.yaml @@ -7,14 +7,14 @@ database: host: localhost port: 3006 username: root - password: texpixel#pwd123! + password: root123 dbname: doc_ai max_idle: 10 max_open: 100 redis: addr: localhost:6079 - password: yoge@123321! + password: redis123 db: 0 limit: diff --git a/config/config_prod.yaml b/config/config_prod.yaml index 38d6b2e..a058c16 100644 --- a/config/config_prod.yaml +++ b/config/config_prod.yaml @@ -66,4 +66,4 @@ email: username: "support@texpixel.com" password: "8bPw2W9LlgHSTTfk" resend: - api_key: "re_xxxxxxxxxxxx" + api_key: "re_dZxRaFAB_D5YME7u6kdRmDxqw4v1G7t87" diff --git a/deploy_prod.sh b/deploy_prod.sh deleted file mode 100755 index 37d1236..0000000 --- a/deploy_prod.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -docker build -t crpi-8s2ierii2xan4klg.cn-beijing.personal.cr.aliyuncs.com/texpixel/doc_ai_backend:latest . && docker push crpi-8s2ierii2xan4klg.cn-beijing.personal.cr.aliyuncs.com/texpixel/doc_ai_backend:latest - -ssh ecs << 'ENDSSH' -docker stop doc_ai doc_ai_backend 2>/dev/null || true -docker rm doc_ai doc_ai_backend 2>/dev/null || true -docker pull crpi-8s2ierii2xan4klg.cn-beijing.personal.cr.aliyuncs.com/texpixel/doc_ai_backend:latest -docker run -d --name doc_ai -p 8024:8024 --restart unless-stopped crpi-8s2ierii2xan4klg.cn-beijing.personal.cr.aliyuncs.com/texpixel/doc_ai_backend:latest -env=prod -ENDSSH \ No newline at end of file diff --git a/go.mod b/go.mod index b67b2f8..aaf54ed 100644 --- a/go.mod +++ b/go.mod @@ -75,7 +75,7 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/go.sum b/go.sum index 7722dea..2559e91 100644 --- a/go.sum +++ b/go.sum @@ -219,8 +219,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= diff --git a/internal/model/pdf/request.go b/internal/model/pdf/request.go new file mode 100644 index 0000000..0a61a07 --- /dev/null +++ b/internal/model/pdf/request.go @@ -0,0 +1,34 @@ +package pdf + +// CreatePDFRecognitionRequest 创建PDF识别任务 +type CreatePDFRecognitionRequest struct { + FileURL string `json:"file_url" binding:"required"` + FileHash string `json:"file_hash" binding:"required"` + FileName string `json:"file_name" binding:"required"` + UserID int64 `json:"user_id"` +} + +// GetPDFTaskRequest URI 参数 +type GetPDFTaskRequest struct { + TaskNo string `uri:"task_no" binding:"required"` +} + +// CreatePDFTaskResponse 创建任务响应 +type CreatePDFTaskResponse struct { + TaskNo string `json:"task_no"` + Status int `json:"status"` +} + +// PDFPageResult 单页结果 +type PDFPageResult struct { + PageNumber int `json:"page_number"` + Markdown string `json:"markdown"` +} + +// GetPDFTaskResponse 查询任务状态和结果 +type GetPDFTaskResponse struct { + TaskNo string `json:"task_no"` + Status int `json:"status"` + TotalPages int `json:"total_pages"` + Pages []PDFPageResult `json:"pages"` +} diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 8f12f2d..e3632c3 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -23,16 +23,16 @@ type UserInfoResponse struct { Email string `json:"email"` } -type EmailCodeSendRequest struct { +type EmailVerifyCodeRequest struct { Email string `json:"email" binding:"required,email"` } -type EmailCodeSendResponse struct{} +type EmailVerifyCodeResponse struct{} type EmailRegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - Code string `json:"code" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + VerifyCode string `json:"code" binding:"required"` } type EmailRegisterResponse struct { diff --git a/internal/service/pdf_recognition_service.go b/internal/service/pdf_recognition_service.go new file mode 100644 index 0000000..ab9a469 --- /dev/null +++ b/internal/service/pdf_recognition_service.go @@ -0,0 +1,348 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "time" + + pdfmodel "gitea.com/texpixel/document_ai/internal/model/pdf" + "gitea.com/texpixel/document_ai/internal/storage/cache" + "gitea.com/texpixel/document_ai/internal/storage/dao" + "gitea.com/texpixel/document_ai/pkg/common" + "gitea.com/texpixel/document_ai/pkg/httpclient" + "gitea.com/texpixel/document_ai/pkg/log" + "gitea.com/texpixel/document_ai/pkg/oss" + "gitea.com/texpixel/document_ai/pkg/requestid" + "gitea.com/texpixel/document_ai/pkg/utils" + "gorm.io/gorm" + + "gitea.com/texpixel/document_ai/internal/model/formula" +) + +const ( + pdfMaxPages = 10 + pdfOCREndpoint = "https://cloud.texpixel.com:10443/doc_process/v1/image/ocr" +) + +// PDFRecognitionService 处理 PDF 识别任务 +type PDFRecognitionService struct { + db *gorm.DB + queueLimit chan struct{} + stopChan chan struct{} + httpClient *httpclient.Client +} + +func NewPDFRecognitionService() *PDFRecognitionService { + s := &PDFRecognitionService{ + db: dao.DB, + queueLimit: make(chan struct{}, 3), + stopChan: make(chan struct{}), + httpClient: httpclient.NewClient(nil), + } + + utils.SafeGo(func() { + lock, err := cache.GetPDFDistributedLock(context.Background()) + if err != nil || !lock { + log.Error(context.Background(), "func", "NewPDFRecognitionService", "msg", "获取PDF分布式锁失败") + return + } + s.processPDFQueue(context.Background()) + }) + + return s +} + +// CreatePDFTask 创建识别任务并入队 +func (s *PDFRecognitionService) CreatePDFTask(ctx context.Context, req *pdfmodel.CreatePDFRecognitionRequest) (*dao.RecognitionTask, error) { + task := &dao.RecognitionTask{ + UserID: req.UserID, + TaskUUID: utils.NewUUID(), + TaskType: dao.TaskTypePDF, + Status: dao.TaskStatusPending, + FileURL: req.FileURL, + FileName: req.FileName, + FileHash: req.FileHash, + IP: common.GetIPFromContext(ctx), + } + + if err := dao.NewRecognitionTaskDao().Create(dao.DB.WithContext(ctx), task); err != nil { + log.Error(ctx, "func", "CreatePDFTask", "msg", "创建任务失败", "error", err) + return nil, common.NewError(common.CodeDBError, "创建任务失败", err) + } + + if _, err := cache.PushPDFTask(ctx, task.ID); err != nil { + log.Error(ctx, "func", "CreatePDFTask", "msg", "推入队列失败", "error", err) + return nil, common.NewError(common.CodeSystemError, "推入队列失败", err) + } + + return task, nil +} + +// GetPDFTask 查询任务状态和结果 +func (s *PDFRecognitionService) GetPDFTask(ctx context.Context, taskNo string, callerUserID int64) (*pdfmodel.GetPDFTaskResponse, error) { + sess := dao.DB.WithContext(ctx) + task, err := dao.NewRecognitionTaskDao().GetByTaskNo(sess, taskNo) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, common.NewError(common.CodeNotFound, "任务不存在", err) + } + return nil, common.NewError(common.CodeDBError, "查询任务失败", err) + } + + // 类型校验:防止公式任务被当成 PDF 解析 + if task.TaskType != dao.TaskTypePDF { + return nil, common.NewError(common.CodeNotFound, "任务不存在", nil) + } + + // 归属校验:已登录用户只能查询自己的任务 + if callerUserID != 0 && task.UserID != 0 && callerUserID != task.UserID { + return nil, common.NewError(common.CodeNotFound, "任务不存在", nil) + } + + resp := &pdfmodel.GetPDFTaskResponse{ + TaskNo: taskNo, + Status: int(task.Status), + } + + if task.Status != dao.TaskStatusCompleted { + return resp, nil + } + + result, err := dao.NewRecognitionResultDao().GetByTaskID(sess, task.ID) + if err != nil || result == nil { + return nil, common.NewError(common.CodeDBError, "查询识别结果失败", err) + } + + pages, err := result.GetPDFContent() + if err != nil { + return nil, common.NewError(common.CodeSystemError, "解析识别结果失败", err) + } + + resp.TotalPages = len(pages) + for _, p := range pages { + resp.Pages = append(resp.Pages, pdfmodel.PDFPageResult{ + PageNumber: p.PageNumber, + Markdown: p.Markdown, + }) + } + + return resp, nil +} + +// processPDFQueue 持续消费队列 +func (s *PDFRecognitionService) processPDFQueue(ctx context.Context) { + for { + select { + case <-s.stopChan: + return + default: + s.processOnePDFTask(ctx) + } + } +} + +func (s *PDFRecognitionService) processOnePDFTask(ctx context.Context) { + s.queueLimit <- struct{}{} + defer func() { <-s.queueLimit }() + + taskID, err := cache.PopPDFTask(ctx) + if err != nil { + log.Error(ctx, "func", "processOnePDFTask", "msg", "获取任务失败", "error", err) + return + } + + task, err := dao.NewRecognitionTaskDao().GetTaskByID(dao.DB.WithContext(ctx), taskID) + if err != nil || task == nil { + log.Error(ctx, "func", "processOnePDFTask", "msg", "任务不存在", "task_id", taskID) + return + } + + ctx = context.WithValue(ctx, utils.RequestIDKey, task.TaskUUID) + requestid.SetRequestID(task.TaskUUID, func() { + if err := s.processPDFTask(ctx, taskID, task.FileURL); err != nil { + log.Error(ctx, "func", "processOnePDFTask", "msg", "处理PDF任务失败", "error", err) + } + }) +} + +// processPDFTask 核心处理:下载 → pre-hook → 逐页OCR → 写入DB +func (s *PDFRecognitionService) processPDFTask(ctx context.Context, taskID int64, fileURL string) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + taskDao := dao.NewRecognitionTaskDao() + resultDao := dao.NewRecognitionResultDao() + + isSuccess := false + defer func() { + status, remark := dao.TaskStatusFailed, "任务处理失败" + if isSuccess { + status, remark = dao.TaskStatusCompleted, "" + } + _ = taskDao.Update(dao.DB.WithContext(context.Background()), + map[string]interface{}{"id": taskID}, + map[string]interface{}{"status": status, "completed_at": time.Now(), "remark": remark}, + ) + }() + + // 更新为处理中 + if err := taskDao.Update(dao.DB.WithContext(ctx), + map[string]interface{}{"id": taskID}, + map[string]interface{}{"status": dao.TaskStatusProcessing}, + ); err != nil { + return fmt.Errorf("更新任务状态失败: %w", err) + } + + // 下载 PDF + reader, err := oss.DownloadFile(ctx, fileURL) + if err != nil { + return fmt.Errorf("下载PDF失败: %w", err) + } + defer reader.Close() + + pdfBytes, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("读取PDF数据失败: %w", err) + } + + // pre-hook: 用 pdftoppm 渲染前 pdfMaxPages 页为 PNG + pageImages, err := renderPDFPages(ctx, pdfBytes, pdfMaxPages) + if err != nil { + return fmt.Errorf("渲染PDF页面失败: %w", err) + } + + processPages := len(pageImages) + log.Info(ctx, "func", "processPDFTask", "msg", "开始处理PDF", + "task_id", taskID, "process_pages", processPages) + + // 逐页 OCR,结果收集 + var pages []dao.PDFPageContent + for i, imgBytes := range pageImages { + ocrResult, err := s.callOCR(ctx, imgBytes) + if err != nil { + return fmt.Errorf("OCR第%d页失败: %w", i+1, err) + } + + pages = append(pages, dao.PDFPageContent{ + PageNumber: i + 1, + Markdown: ocrResult.Markdown, + }) + log.Info(ctx, "func", "processPDFTask", "msg", "页面OCR完成", + "page", i+1, "total", processPages) + } + + // 序列化并写入 DB(单行) + contentJSON, err := dao.MarshalPDFContent(pages) + if err != nil { + return fmt.Errorf("序列化PDF内容失败: %w", err) + } + + dbResult := dao.RecognitionResult{ + TaskID: taskID, + TaskType: dao.TaskTypePDF, + Content: contentJSON, + } + if err := dbResult.SetMetaData(dao.ResultMetaData{TotalNum: processPages}); err != nil { + return fmt.Errorf("序列化MetaData失败: %w", err) + } + if err := resultDao.Create(dao.DB.WithContext(ctx), dbResult); err != nil { + return fmt.Errorf("保存PDF结果失败: %w", err) + } + + isSuccess = true + return nil +} + +// renderPDFPages 使用 pdftoppm 将 PDF 渲染为 PNG 字节切片,最多渲染 maxPages 页 +func renderPDFPages(ctx context.Context, pdfBytes []byte, maxPages int) ([][]byte, error) { + tmpDir, err := os.MkdirTemp("", "pdf-ocr-*") + if err != nil { + return nil, fmt.Errorf("创建临时目录失败: %w", err) + } + defer os.RemoveAll(tmpDir) + + pdfPath := filepath.Join(tmpDir, "input.pdf") + if err := os.WriteFile(pdfPath, pdfBytes, 0600); err != nil { + return nil, fmt.Errorf("写入临时PDF失败: %w", err) + } + + outPrefix := filepath.Join(tmpDir, "page") + cmd := exec.CommandContext(ctx, "pdftoppm", + "-r", "150", + "-png", + "-l", fmt.Sprintf("%d", maxPages), + pdfPath, + outPrefix, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("pdftoppm失败: %w, output: %s", err, string(out)) + } + + files, err := filepath.Glob(filepath.Join(tmpDir, "page-*.png")) + if err != nil { + return nil, fmt.Errorf("查找渲染输出文件失败: %w", err) + } + if len(files) == 0 { + return nil, fmt.Errorf("pdftoppm未输出任何页面") + } + sort.Strings(files) + + pages := make([][]byte, 0, len(files)) + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, fmt.Errorf("读取页面图片失败: %w", err) + } + pages = append(pages, data) + } + + return pages, nil +} + +// callOCR 调用与公式识别相同的下游 OCR 接口 +func (s *PDFRecognitionService) callOCR(ctx context.Context, imgBytes []byte) (*formula.ImageOCRResponse, error) { + reqBody := map[string]string{ + "image_base64": base64.StdEncoding.EncodeToString(imgBytes), + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + headers := map[string]string{ + "Content-Type": "application/json", + utils.RequestIDHeaderKey: utils.GetRequestIDFromContext(ctx), + } + + resp, err := s.httpClient.RequestWithRetry(ctx, http.MethodPost, pdfOCREndpoint, bytes.NewReader(jsonData), headers) + if err != nil { + return nil, fmt.Errorf("请求OCR接口失败: %w", err) + } + defer resp.Body.Close() + + // 下游非 2xx 视为失败,避免把错误响应 body 当成识别结果存库 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("OCR接口返回非200状态: %d, body: %s", resp.StatusCode, string(body)) + } + + var ocrResp formula.ImageOCRResponse + if err := json.NewDecoder(resp.Body).Decode(&ocrResp); err != nil { + return nil, fmt.Errorf("解析OCR响应失败: %w", err) + } + + return &ocrResp, nil +} + +func (s *PDFRecognitionService) Stop() { + close(s.stopChan) +} diff --git a/internal/service/recognition_service.go b/internal/service/recognition_service.go index 4926799..6c66f8d 100644 --- a/internal/service/recognition_service.go +++ b/internal/service/recognition_service.go @@ -169,18 +169,21 @@ func (s *RecognitionService) GetFormualTask(ctx context.Context, taskNo string) return nil, common.NewError(common.CodeDBError, "查询任务结果失败", err) } - // 构建 Markdown 格式 - markdown := taskRet.Markdown - if markdown == "" { - markdown = fmt.Sprintf("$$%s$$", taskRet.Latex) + formulaContent, err := taskRet.GetFormulaContent() + if err != nil { + log.Error(ctx, "func", "GetFormualTask", "msg", "解析公式内容失败", "error", err) + return nil, common.NewError(common.CodeSystemError, "解析识别结果失败", err) + } + markdown := formulaContent.Markdown + if markdown == "" { + markdown = fmt.Sprintf("$$%s$$", formulaContent.Latex) } - return &formula.GetFormulaTaskResponse{ TaskNo: taskNo, - Latex: taskRet.Latex, + Latex: formulaContent.Latex, Markdown: markdown, - MathML: taskRet.MathML, - MML: taskRet.MML, + MathML: formulaContent.MathML, + MML: formulaContent.MML, Status: int(task.Status), }, nil } @@ -539,14 +542,26 @@ func (s *RecognitionService) processFormulaTask(ctx context.Context, taskID int6 log.Error(ctx, "func", "processFormulaTask", "msg", "解析响应JSON失败", "error", err) return err } - err = resultDao.Create(tx, dao.RecognitionResult{ - TaskID: taskID, - TaskType: dao.TaskTypeFormula, + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ Latex: ocrResp.Latex, Markdown: ocrResp.Markdown, MathML: ocrResp.MathML, MML: ocrResp.MML, }) + if err != nil { + log.Error(ctx, "func", "processFormulaTask", "msg", "序列化公式内容失败", "error", err) + return err + } + result := dao.RecognitionResult{ + TaskID: taskID, + TaskType: dao.TaskTypeFormula, + Content: contentJSON, + } + if err = result.SetMetaData(dao.ResultMetaData{TotalNum: 1}); err != nil { + log.Error(ctx, "func", "processFormulaTask", "msg", "序列化MetaData失败", "error", err) + return err + } + err = resultDao.Create(tx, result) if err != nil { log.Error(ctx, "func", "processFormulaTask", "msg", "保存任务结果失败", "error", err) return err @@ -662,15 +677,25 @@ func (s *RecognitionService) processVLFormulaTask(ctx context.Context, taskID in return err } if result == nil { - formulaRes := &dao.RecognitionResult{TaskID: taskID, TaskType: dao.TaskTypeFormula, Latex: latex} - err = resultDao.Create(dao.DB.WithContext(ctx), *formulaRes) + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{Latex: latex}) + if err != nil { + log.Error(ctx, "func", "processVLFormulaTask", "msg", "序列化公式内容失败", "error", err) + return err + } + newResult := dao.RecognitionResult{TaskID: taskID, TaskType: dao.TaskTypeFormula, Content: contentJSON} + _ = newResult.SetMetaData(dao.ResultMetaData{TotalNum: 1}) + err = resultDao.Create(dao.DB.WithContext(ctx), newResult) if err != nil { log.Error(ctx, "func", "processVLFormulaTask", "msg", "创建任务结果失败", "error", err) return err } } else { - result.Latex = latex - err = resultDao.Update(dao.DB.WithContext(ctx), result.ID, map[string]interface{}{"latex": latex}) + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{Latex: latex}) + if err != nil { + log.Error(ctx, "func", "processVLFormulaTask", "msg", "序列化公式内容失败", "error", err) + return err + } + err = resultDao.Update(dao.DB.WithContext(ctx), result.ID, map[string]interface{}{"content": contentJSON}) if err != nil { log.Error(ctx, "func", "processVLFormulaTask", "msg", "更新任务结果失败", "error", err) return err @@ -851,23 +876,35 @@ func (s *RecognitionService) processMathpixTask(ctx context.Context, taskID int6 if result == nil { // 创建新结果 - err = resultDao.Create(dao.DB.WithContext(ctx), dao.RecognitionResult{ - TaskID: taskID, - TaskType: dao.TaskTypeFormula, + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ Latex: mathpixResp.LatexStyled, Markdown: mathpixResp.Text, MathML: mathpixResp.GetMathML(), }) + if err != nil { + log.Error(ctx, "func", "processMathpixTask", "msg", "序列化公式内容失败", "error", err) + return err + } + newResult := dao.RecognitionResult{TaskID: taskID, TaskType: dao.TaskTypeFormula, Content: contentJSON} + _ = newResult.SetMetaData(dao.ResultMetaData{TotalNum: 1}) + err = resultDao.Create(dao.DB.WithContext(ctx), newResult) if err != nil { log.Error(ctx, "func", "processMathpixTask", "msg", "创建任务结果失败", "error", err) return err } } else { // 更新现有结果 + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ + Latex: mathpixResp.LatexStyled, + Markdown: mathpixResp.Text, + MathML: mathpixResp.GetMathML(), + }) + if err != nil { + log.Error(ctx, "func", "processMathpixTask", "msg", "序列化公式内容失败", "error", err) + return err + } err = resultDao.Update(dao.DB.WithContext(ctx), result.ID, map[string]interface{}{ - "latex": mathpixResp.LatexStyled, - "markdown": mathpixResp.Text, - "mathml": mathpixResp.GetMathML(), + "content": contentJSON, }) if err != nil { log.Error(ctx, "func", "processMathpixTask", "msg", "更新任务结果失败", "error", err) @@ -1027,23 +1064,35 @@ func (s *RecognitionService) processBaiduOCRTask(ctx context.Context, taskID int if result == nil { // 创建新结果 - err = resultDao.Create(dao.DB.WithContext(ctx), dao.RecognitionResult{ - TaskID: taskID, - TaskType: dao.TaskTypeFormula, + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ Markdown: markdownResult, Latex: latex, MathML: mml, }) + if err != nil { + log.Error(ctx, "func", "processBaiduOCRTask", "msg", "序列化公式内容失败", "error", err) + return err + } + newResult := dao.RecognitionResult{TaskID: taskID, TaskType: dao.TaskTypeFormula, Content: contentJSON} + _ = newResult.SetMetaData(dao.ResultMetaData{TotalNum: 1}) + err = resultDao.Create(dao.DB.WithContext(ctx), newResult) if err != nil { log.Error(ctx, "func", "processBaiduOCRTask", "msg", "创建任务结果失败", "error", err) return err } } else { // 更新现有结果 + contentJSON, err := dao.MarshalFormulaContent(dao.FormulaContent{ + Markdown: markdownResult, + Latex: latex, + MathML: mml, + }) + if err != nil { + log.Error(ctx, "func", "processBaiduOCRTask", "msg", "序列化公式内容失败", "error", err) + return err + } err = resultDao.Update(dao.DB.WithContext(ctx), result.ID, map[string]interface{}{ - "markdown": markdownResult, - "latex": latex, - "mathml": mml, + "content": contentJSON, }) if err != nil { log.Error(ctx, "func", "processBaiduOCRTask", "msg", "更新任务结果失败", "error", err) diff --git a/internal/service/task.go b/internal/service/task.go index 9317c72..d099b2b 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -89,17 +89,17 @@ func (svc *TaskService) GetTaskList(ctx context.Context, req *task.TaskListReque Total: total, } for _, item := range tasks { - var latex string - var markdown string - var mathML string - var mml string + var latex, markdown, mathML, mml string recognitionResult := recognitionResultMap[item.ID] - if recognitionResult != nil { - latex = recognitionResult.Latex - markdown = recognitionResult.Markdown - mathML = recognitionResult.MathML - mml = recognitionResult.MML + if recognitionResult != nil && recognitionResult.TaskType == dao.TaskTypeFormula { + if fc, err := recognitionResult.GetFormulaContent(); err == nil { + latex = fc.Latex + markdown = fc.Markdown + mathML = fc.MathML + mml = fc.MML + } } + // PDF 类型的 TaskListDTO 暂不展开 content(列表页只显示状态) originURL, err := oss.GetDownloadURL(ctx, item.FileURL) if err != nil { log.Error(ctx, "func", "GetTaskList", "msg", "get origin url failed", "error", err) @@ -148,10 +148,18 @@ func (svc *TaskService) ExportTask(ctx context.Context, req *task.ExportTaskRequ return nil, "", errors.New("recognition result not found") } - markdown := recognitionResult.Markdown - if markdown == "" { - log.Error(ctx, "func", "ExportTask", "msg", "markdown not found") - return nil, "", errors.New("markdown not found") + var markdown string + switch recognitionResult.TaskType { + case dao.TaskTypeFormula: + fc, err := recognitionResult.GetFormulaContent() + if err != nil || fc.Markdown == "" { + log.Error(ctx, "func", "ExportTask", "msg", "公式结果解析失败或markdown为空", "error", err) + return nil, "", errors.New("markdown not found") + } + markdown = fc.Markdown + default: + log.Error(ctx, "func", "ExportTask", "msg", "不支持的导出任务类型", "task_type", recognitionResult.TaskType) + return nil, "", errors.New("unsupported task type for export") } // 获取文件名(去掉扩展名) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index f9fa43e..499987f 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -21,12 +21,14 @@ import ( ) type UserService struct { - userDao *dao.UserDao + userDao *dao.UserDao + emailSendLogDao *dao.EmailSendLogDao } func NewUserService() *UserService { return &UserService{ - userDao: dao.NewUserDao(), + userDao: dao.NewUserDao(), + emailSendLogDao: dao.NewEmailSendLogDao(), } } @@ -116,10 +118,10 @@ func (svc *UserService) GetUserInfo(ctx context.Context, uid int64) (*dao.User, return user, nil } -func (svc *UserService) SendEmailCode(ctx context.Context, emailAddr string) error { +func (svc *UserService) SendEmailVerifyCode(ctx context.Context, emailAddr string) error { limit, err := cache.GetUserSendEmailLimit(ctx, emailAddr) if err != nil { - log.Error(ctx, "func", "SendEmailCode", "msg", "get send email limit error", "error", err) + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "get send email limit error", "error", err) return err } if limit >= cache.UserSendEmailLimitCount { @@ -128,33 +130,53 @@ func (svc *UserService) SendEmailCode(ctx context.Context, emailAddr string) err code := fmt.Sprintf("%06d", rand.Intn(1000000)) - subject := "TexPixel 邮箱验证码" - body := fmt.Sprintf(`
您的验证码为:%s,10分钟内有效,请勿泄露。
`, code) - if err = email.Send(ctx, emailAddr, subject, body); err != nil { - log.Error(ctx, "func", "SendEmailCode", "msg", "send email error", "error", err) + subject, body := email.BuildVerifyCodeEmail(emailAddr, code) + if err := email.Send(ctx, emailAddr, subject, body); err != nil { + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "send email error", "error", err) return err } if cacheErr := cache.SetUserEmailCode(ctx, emailAddr, code); cacheErr != nil { - log.Error(ctx, "func", "SendEmailCode", "msg", "set email code error", "error", cacheErr) + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "set email code error", "error", cacheErr) } if cacheErr := cache.SetUserSendEmailLimit(ctx, emailAddr); cacheErr != nil { - log.Error(ctx, "func", "SendEmailCode", "msg", "set send email limit error", "error", cacheErr) + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "set send email limit error", "error", cacheErr) } + + record := &dao.EmailSendLog{Email: emailAddr, Status: dao.EmailSendStatusSent} + if logErr := svc.emailSendLogDao.Create(dao.DB.WithContext(ctx), record); logErr != nil { + log.Error(ctx, "func", "SendEmailVerifyCode", "msg", "create email send log error", "error", logErr) + } + return nil } -func (svc *UserService) RegisterByEmail(ctx context.Context, emailAddr, password, code string) (uid int64, err error) { +func (svc *UserService) RegisterByEmail(ctx context.Context, emailAddr, password, verifyCode string) (uid int64, err error) { storedCode, err := cache.GetUserEmailCode(ctx, emailAddr) if err != nil { log.Error(ctx, "func", "RegisterByEmail", "msg", "get email code error", "error", err) return 0, err } - if storedCode == "" || storedCode != code { - log.Warn(ctx, "func", "RegisterByEmail", "msg", "invalid email code", "email", emailAddr) + + if storedCode == "" || storedCode != verifyCode { return 0, common.ErrEmailCodeError } + _ = cache.DeleteUserEmailCode(ctx, emailAddr) + + uid, err = svc.registerByEmailInternal(ctx, emailAddr, password) + if err != nil { + return 0, err + } + + if logErr := svc.emailSendLogDao.MarkRegistered(dao.DB.WithContext(ctx), emailAddr); logErr != nil { + log.Error(ctx, "func", "RegisterByEmail", "msg", "mark email send log registered error", "error", logErr) + } + + return uid, nil +} + +func (svc *UserService) registerByEmailInternal(ctx context.Context, emailAddr, password string) (uid int64, err error) { existingUser, err := svc.userDao.GetByEmail(dao.DB.WithContext(ctx), emailAddr) if err != nil { log.Error(ctx, "func", "RegisterByEmail", "msg", "get user by email error", "error", err) diff --git a/internal/storage/cache/pdf.go b/internal/storage/cache/pdf.go new file mode 100644 index 0000000..fa9b4b6 --- /dev/null +++ b/internal/storage/cache/pdf.go @@ -0,0 +1,27 @@ +package cache + +import ( + "context" + "strconv" +) + +const ( + PDFRecognitionTaskQueue = "pdf_recognition_queue" + PDFRecognitionDistLock = "pdf_recognition_dist_lock" +) + +func PushPDFTask(ctx context.Context, taskID int64) (int64, error) { + return RedisClient.LPush(ctx, PDFRecognitionTaskQueue, taskID).Result() +} + +func PopPDFTask(ctx context.Context) (int64, error) { + result, err := RedisClient.BRPop(ctx, 0, PDFRecognitionTaskQueue).Result() + if err != nil { + return 0, err + } + return strconv.ParseInt(result[1], 10, 64) +} + +func GetPDFDistributedLock(ctx context.Context) (bool, error) { + return RedisClient.SetNX(ctx, PDFRecognitionDistLock, "locked", DefaultLockTimeout).Result() +} diff --git a/internal/storage/cache/user.go b/internal/storage/cache/user.go index 2cc807e..62c8603 100644 --- a/internal/storage/cache/user.go +++ b/internal/storage/cache/user.go @@ -88,10 +88,6 @@ func SetUserEmailCode(ctx context.Context, email, code string) error { return RedisClient.Set(ctx, fmt.Sprintf(UserEmailCodePrefix, email), code, UserEmailCodeTTL).Err() } -func DeleteUserEmailCode(ctx context.Context, email string) error { - return RedisClient.Del(ctx, fmt.Sprintf(UserEmailCodePrefix, email)).Err() -} - func GetUserSendEmailLimit(ctx context.Context, email string) (int, error) { limit, err := RedisClient.Get(ctx, fmt.Sprintf(UserSendEmailLimit, email)).Result() if err != nil { @@ -104,13 +100,16 @@ func GetUserSendEmailLimit(ctx context.Context, email string) (int, error) { } func SetUserSendEmailLimit(ctx context.Context, email string) error { - key := fmt.Sprintf(UserSendEmailLimit, email) - count, err := RedisClient.Incr(ctx, key).Result() + count, err := RedisClient.Incr(ctx, fmt.Sprintf(UserSendEmailLimit, email)).Result() if err != nil { return err } if count > UserSendEmailLimitCount { return errors.New("send email limit") } - return RedisClient.Expire(ctx, key, UserSendEmailLimitTTL).Err() + return RedisClient.Expire(ctx, fmt.Sprintf(UserSendEmailLimit, email), UserSendEmailLimitTTL).Err() +} + +func DeleteUserEmailCode(ctx context.Context, email string) error { + return RedisClient.Del(ctx, fmt.Sprintf(UserEmailCodePrefix, email)).Err() } diff --git a/internal/storage/dao/email_send_log.go b/internal/storage/dao/email_send_log.go new file mode 100644 index 0000000..18947b5 --- /dev/null +++ b/internal/storage/dao/email_send_log.go @@ -0,0 +1,50 @@ +package dao + +import ( + "gorm.io/gorm" +) + +type EmailSendStatus int8 + +const ( + EmailSendStatusSent EmailSendStatus = 0 // 已发送,用户未注册 + EmailSendStatusRegistered EmailSendStatus = 1 // 用户已完成注册 +) + +type EmailSendLog struct { + BaseModel + Email string `gorm:"column:email;type:varchar(255);not null;comment:邮箱地址" json:"email"` + Status EmailSendStatus `gorm:"column:status;type:tinyint;not null;default:0;comment:状态: 0=已发送未注册 1=已注册" json:"status"` +} + +func (e *EmailSendLog) TableName() string { + return "email_send_log" +} + +type EmailSendLogDao struct{} + +func NewEmailSendLogDao() *EmailSendLogDao { + return &EmailSendLogDao{} +} + +func (d *EmailSendLogDao) Create(tx *gorm.DB, log *EmailSendLog) error { + return tx.Create(log).Error +} + +func (d *EmailSendLogDao) GetLatestByEmail(tx *gorm.DB, email string) (*EmailSendLog, error) { + var record EmailSendLog + err := tx.Where("email = ?", email).Order("id DESC").First(&record).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &record, nil +} + +func (d *EmailSendLogDao) MarkRegistered(tx *gorm.DB, email string) error { + return tx.Model(&EmailSendLog{}). + Where("email = ? AND status = ?", email, EmailSendStatusSent). + Update("status", EmailSendStatusRegistered).Error +} diff --git a/internal/storage/dao/result.go b/internal/storage/dao/result.go index 43acb70..4c0da86 100644 --- a/internal/storage/dao/result.go +++ b/internal/storage/dao/result.go @@ -1,45 +1,104 @@ package dao import ( + "encoding/json" + "gorm.io/gorm" ) -type RecognitionResult struct { - BaseModel - 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"` - Latex string `json:"latex" gorm:"column:latex;type:text;not null;default:''"` - 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 格式 - MML string `json:"mml" gorm:"column:mml;type:text;not null;default:''"` // MML 格式 +// FormulaContent 公式识别的 content 字段结构 +type FormulaContent struct { + Latex string `json:"latex"` + Markdown string `json:"markdown"` + MathML string `json:"mathml"` + MML string `json:"mml"` } -type RecognitionResultDao struct { +// PDFPageContent PDF 单页识别结果 +type PDFPageContent struct { + PageNumber int `json:"page_number"` + Markdown string `json:"markdown"` } +// ResultMetaData recognition_results.meta_data 字段结构 +type ResultMetaData struct { + TotalNum int `json:"total_num"` +} + +// RecognitionResult recognition_results 表模型 +type RecognitionResult struct { + BaseModel + TaskID int64 `gorm:"column:task_id;bigint;not null;default:0;index;comment:任务ID" json:"task_id"` + TaskType TaskType `gorm:"column:task_type;varchar(16);not null;comment:任务类型;default:''" json:"task_type"` + MetaData string `gorm:"column:meta_data;type:json;comment:元数据" json:"meta_data"` + Content string `gorm:"column:content;type:json;comment:识别内容JSON" json:"content"` +} + +// SetMetaData 序列化并写入 MetaData 字段 +func (r *RecognitionResult) SetMetaData(meta ResultMetaData) error { + b, err := json.Marshal(meta) + if err != nil { + return err + } + r.MetaData = string(b) + return nil +} + +// GetFormulaContent 从 Content 字段反序列化公式结果 +func (r *RecognitionResult) GetFormulaContent() (*FormulaContent, error) { + var c FormulaContent + if err := json.Unmarshal([]byte(r.Content), &c); err != nil { + return nil, err + } + return &c, nil +} + +// GetPDFContent 从 Content 字段反序列化 PDF 分页结果 +func (r *RecognitionResult) GetPDFContent() ([]PDFPageContent, error) { + var pages []PDFPageContent + if err := json.Unmarshal([]byte(r.Content), &pages); err != nil { + return nil, err + } + return pages, nil +} + +// MarshalFormulaContent 将公式结果序列化为 JSON 字符串(供写入 Content) +func MarshalFormulaContent(c FormulaContent) (string, error) { + b, err := json.Marshal(c) + return string(b), err +} + +// MarshalPDFContent 将 PDF 分页结果序列化为 JSON 字符串(供写入 Content) +func MarshalPDFContent(pages []PDFPageContent) (string, error) { + b, err := json.Marshal(pages) + return string(b), err +} + +type RecognitionResultDao struct{} + func NewRecognitionResultDao() *RecognitionResultDao { return &RecognitionResultDao{} } -// 模型方法 func (dao *RecognitionResultDao) Create(tx *gorm.DB, data RecognitionResult) error { return tx.Create(&data).Error } -func (dao *RecognitionResultDao) GetByTaskID(tx *gorm.DB, taskID int64) (result *RecognitionResult, err error) { - result = &RecognitionResult{} - err = tx.Where("task_id = ?", taskID).First(result).Error +func (dao *RecognitionResultDao) GetByTaskID(tx *gorm.DB, taskID int64) (*RecognitionResult, error) { + result := &RecognitionResult{} + err := tx.Where("task_id = ?", taskID).First(result).Error if err != nil && err == gorm.ErrRecordNotFound { return nil, nil } - return -} - -func (dao *RecognitionResultDao) GetByTaskIDs(tx *gorm.DB, taskIDs []int64) (results []*RecognitionResult, err error) { - err = tx.Where("task_id IN (?)", taskIDs).Find(&results).Error - return + return result, err } func (dao *RecognitionResultDao) Update(tx *gorm.DB, id int64, updates map[string]interface{}) error { return tx.Model(&RecognitionResult{}).Where("id = ?", id).Updates(updates).Error } + +func (dao *RecognitionResultDao) GetByTaskIDs(tx *gorm.DB, taskIDs []int64) ([]*RecognitionResult, error) { + var results []*RecognitionResult + err := tx.Where("task_id IN (?)", taskIDs).Find(&results).Error + return results, err +} diff --git a/internal/storage/dao/task.go b/internal/storage/dao/task.go index 1f148ba..16bae9e 100644 --- a/internal/storage/dao/task.go +++ b/internal/storage/dao/task.go @@ -20,6 +20,7 @@ const ( TaskTypeText TaskType = "TEXT" TaskTypeTable TaskType = "TABLE" TaskTypeLayout TaskType = "LAYOUT" + TaskTypePDF TaskType = "PDF" ) func (t TaskType) String() string { diff --git a/migrations/email_send_log.sql b/migrations/email_send_log.sql new file mode 100644 index 0000000..e4cc67f --- /dev/null +++ b/migrations/email_send_log.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `email_send_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `email` VARCHAR(255) NOT NULL COMMENT '邮箱地址', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0=已发送未注册, 1=已注册', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_email` (`email`), + INDEX `idx_status` (`status`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邮件发送记录表'; diff --git a/migrations/pdf_recognition.sql b/migrations/pdf_recognition.sql new file mode 100644 index 0000000..0843a02 --- /dev/null +++ b/migrations/pdf_recognition.sql @@ -0,0 +1,32 @@ +-- migrations/pdf_recognition.sql +-- 将 recognition_results 表重构为 JSON content schema +-- 执行顺序:加新列 → 洗历史数据 → 删旧列 + +-- Step 1: 新增 JSON 字段(保留旧字段,等数据迁移完再删) +ALTER TABLE `recognition_results` + ADD COLUMN `meta_data` JSON DEFAULT NULL COMMENT '元数据 {"total_num":1}' AFTER `task_type`, + ADD COLUMN `content` JSON DEFAULT NULL COMMENT '识别内容 JSON' AFTER `meta_data`; + +-- Step 2: 将旧列数据洗入新 JSON 字段 +-- 所有现有记录均为 FORMULA 类型(单页),meta_data.total_num = 1 +-- content 结构: {"latex":"...","markdown":"...","mathml":"...","mml":"..."} +UPDATE `recognition_results` +SET + `meta_data` = JSON_OBJECT('total_num', 1), + `content` = JSON_OBJECT( + 'latex', IFNULL(`latex`, ''), + 'markdown', IFNULL(`markdown`, ''), + 'mathml', IFNULL(`mathml`, ''), + 'mml', IFNULL(`mml`, '') + ) +WHERE `content` IS NULL; + +-- Step 3: 验证数据洗涤完成(应返回 0) +-- SELECT COUNT(*) FROM `recognition_results` WHERE `content` IS NULL; + +-- Step 4: 删除旧字段 +ALTER TABLE `recognition_results` + DROP COLUMN `latex`, + DROP COLUMN `markdown`, + DROP COLUMN `mathml`, + DROP COLUMN `mml`; diff --git a/pkg/common/errors.go b/pkg/common/errors.go index 6f823fc..f354217 100644 --- a/pkg/common/errors.go +++ b/pkg/common/errors.go @@ -38,7 +38,7 @@ const ( CodeEmailExistsMsg = "email already registered" CodeEmailNotFoundMsg = "email not found" CodePasswordMismatchMsg = "password mismatch" - CodeEmailCodeErrorMsg = "email code error" + CodeEmailCodeErrorMsg = "email verify code error" CodeEmailSendLimitMsg = "email send limit reached" ) diff --git a/pkg/email/template.go b/pkg/email/template.go new file mode 100644 index 0000000..adf82c3 --- /dev/null +++ b/pkg/email/template.go @@ -0,0 +1,166 @@ +package email + +import "fmt" + +// BuildVerifyCodeEmail returns a locale-appropriate subject and HTML body for +// the verification code email. Chinese domains get a Chinese email; all others +// get an English one. +func BuildVerifyCodeEmail(toEmail, code string) (subject, body string) { + domain := toEmail[lastIndex(toEmail, '@')+1:] + if chineseDomainRe.MatchString(domain) { + return buildVerifyCodeZH(code) + } + return buildVerifyCodeEN(code) +} + +func buildVerifyCodeZH(code string) (subject, body string) { + subject = "您的验证码" + body = fmt.Sprintf(` + + + + +| + + | +
| + + | +