diff --git a/backend/internal/modules/chat/conversation_test.go b/backend/internal/modules/chat/conversation_test.go index 237c147..854adb4 100644 --- a/backend/internal/modules/chat/conversation_test.go +++ b/backend/internal/modules/chat/conversation_test.go @@ -33,7 +33,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB { func TestFindOrderConversationPrefersListingGroup(t *testing.T) { db := setupConversationTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) owner := model.User{Phone: "13800001001", Nickname: "号主"} @@ -96,7 +96,7 @@ func TestFindOrderConversationPrefersListingGroup(t *testing.T) { func TestFindOrderConversationFallsBackToOrderGroup(t *testing.T) { db := setupConversationTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) owner := model.User{Phone: "13800001003", Nickname: "号主"} diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 259a01b..91efb5d 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -96,7 +96,7 @@ func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": stats}) } -// RecognizeQrCodeGroupNameHandler 调用 PaddleOCR API 识别二维码图片中的企业微信群名 +// RecognizeQrCodeGroupNameHandler 同步调用 PaddleOCR API 识别二维码图片中的企业微信群名(保留向后兼容) func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { @@ -131,6 +131,70 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": result}) } +// SubmitOCRJobHandler 提交 OCR 任务到 PaddleOCR,立即返回任务 ID +func (h *Handler) SubmitOCRJobHandler(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传二维码图片"}) + return + } + defer file.Close() + + jobID, err := h.service.repo.SubmitOCRJob( + c.Request.Context(), + header.Filename, + header.Header.Get("Content-Type"), + file, + ) + if err != nil { + if errors.Is(err, ErrQrCodeOCRNotConfigured) { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先在系统配置中填写 PaddleOCR API Token"}) + return + } + if errors.Is(err, ErrQrCodeOCRInvalidFile) { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的二维码图片"}) + return + } + if errors.Is(err, ErrQrCodeOCRUnavailable) { + c.JSON(http.StatusBadGateway, gin.H{"error": "PaddleOCR 服务暂时不可用"}) + return + } + if errors.Is(err, ErrQrCodeOCRRedisDisabled) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OCR 异步模式需要 Redis"}) + return + } + if errors.Is(err, ErrQrCodeOCRJobNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": gin.H{"job_id": jobID}}) +} + +// GetOCRJobResultHandler 查询异步 OCR 任务状态和结果 +func (h *Handler) GetOCRJobResultHandler(c *gin.Context) { + jobID := c.Param("jobId") + if jobID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少任务 ID"}) + return + } + + record, err := h.service.repo.PollOCRJobResult(c.Request.Context(), jobID) + if err != nil { + if errors.Is(err, ErrQrCodeOCRJobNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "OCR 任务不存在或已过期"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": record}) +} + // UpdateQrCodeHandler 更新二维码 func (h *Handler) UpdateQrCodeHandler(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/backend/internal/modules/chat/qrcode_ocr.go b/backend/internal/modules/chat/qrcode_ocr.go index 008b300..0d5e728 100644 --- a/backend/internal/modules/chat/qrcode_ocr.go +++ b/backend/internal/modules/chat/qrcode_ocr.go @@ -15,18 +15,30 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/google/uuid" ) const ( - maxQrCodeOCRFileSize = 10 * 1024 * 1024 - qrCodeOCRPollInterval = 2 * time.Second - qrCodeOCRMaxPollTimes = 45 + maxQrCodeOCRFileSize = 10 * 1024 * 1024 + + ocrJobRedisKeyPrefix = "ocr:job:" + ocrJobRedisTTL = 10 * time.Minute + + ocrJobStatusSubmitted = "submitted" + ocrJobStatusRunning = "running" + ocrJobStatusDone = "done" + ocrJobStatusFailed = "failed" ) var ( ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置") ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片") ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用") + ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期") + ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis") + + ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} ) type QrCodeOCRResult struct { @@ -35,6 +47,13 @@ type QrCodeOCRResult struct { RawText string `json:"raw_text"` } +type OCRJobRecord struct { + PaddleJobID string `json:"paddle_job_id"` + Status string `json:"status"` + Result *QrCodeOCRResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + type paddleOCRJobResponse struct { Data struct { JobID string `json:"jobId"` @@ -55,7 +74,51 @@ type paddleOCRJobStatusResponse struct { Error string `json:"error"` } -// RecognizeQrCodeGroupName 调用 PaddleOCR 异步 API 识别二维码图片中的群名。 +func readAndValidateOCRFile(reader io.Reader, contentType string) ([]byte, string, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxQrCodeOCRFileSize+1)) + if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize { + return nil, "", ErrQrCodeOCRInvalidFile + } + if contentType == "" { + contentType = http.DetectContentType(data) + } + if !strings.HasPrefix(contentType, "image/") { + return nil, "", ErrQrCodeOCRInvalidFile + } + return data, contentType, nil +} + +func ocrJobKey(jobID string) string { + return ocrJobRedisKeyPrefix + jobID +} + +func (r *Repository) saveOCRJobRecord(ctx context.Context, jobID string, record *OCRJobRecord) error { + if r.redis == nil { + return ErrQrCodeOCRRedisDisabled + } + raw, err := json.Marshal(record) + if err != nil { + return err + } + return r.redis.Set(ctx, ocrJobKey(jobID), string(raw), ocrJobRedisTTL).Err() +} + +func (r *Repository) getOCRJobRecord(ctx context.Context, jobID string) (*OCRJobRecord, error) { + if r.redis == nil { + return nil, ErrQrCodeOCRRedisDisabled + } + raw, err := r.redis.Get(ctx, ocrJobKey(jobID)).Result() + if err != nil { + return nil, ErrQrCodeOCRJobNotFound + } + var record OCRJobRecord + if err := json.Unmarshal([]byte(raw), &record); err != nil { + return nil, err + } + return &record, nil +} + +// RecognizeQrCodeGroupName 同步调用 PaddleOCR 识别二维码图片中的群名(保留向后兼容)。 func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, contentType string, reader io.Reader) (*QrCodeOCRResult, error) { config, err := r.GetQrCodeOCRConfig(ctx) if err != nil { @@ -64,22 +127,16 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con if strings.TrimSpace(config.Token) == "" { return nil, ErrQrCodeOCRNotConfigured } - data, err := io.ReadAll(io.LimitReader(reader, maxQrCodeOCRFileSize+1)) - if err != nil || len(data) == 0 || len(data) > maxQrCodeOCRFileSize { - return nil, ErrQrCodeOCRInvalidFile - } - if contentType == "" { - contentType = http.DetectContentType(data) - } - if !strings.HasPrefix(contentType, "image/") { - return nil, ErrQrCodeOCRInvalidFile - } - - jobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) + data, contentType, err := readAndValidateOCRFile(reader, contentType) if err != nil { return nil, err } - jsonURL, err := waitPaddleOCRJob(ctx, config, jobID) + + paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) + if err != nil { + return nil, err + } + jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID) if err != nil { return nil, err } @@ -99,6 +156,127 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con }, nil } +// SubmitOCRJob 提交 OCR 任务到 PaddleOCR,立即返回任务 ID 供前端轮询。 +func (r *Repository) SubmitOCRJob(ctx context.Context, filename, contentType string, reader io.Reader) (string, error) { + if r.redis == nil { + return "", ErrQrCodeOCRRedisDisabled + } + config, err := r.GetQrCodeOCRConfig(ctx) + if err != nil { + return "", err + } + if strings.TrimSpace(config.Token) == "" { + return "", ErrQrCodeOCRNotConfigured + } + data, contentType, err := readAndValidateOCRFile(reader, contentType) + if err != nil { + return "", err + } + + paddleJobID, err := submitPaddleOCRJob(ctx, config, filename, contentType, data) + if err != nil { + return "", err + } + + jobID := uuid.NewString() + record := &OCRJobRecord{ + PaddleJobID: paddleJobID, + Status: ocrJobStatusSubmitted, + } + if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil { + return "", err + } + return jobID, nil +} + +// PollOCRJobResult 轮询 OCR 任务状态,每次调用向 PaddleOCR 查询一次并更新缓存。 +func (r *Repository) PollOCRJobResult(ctx context.Context, jobID string) (*OCRJobRecord, error) { + record, err := r.getOCRJobRecord(ctx, jobID) + if err != nil { + return nil, err + } + + if record.Status == ocrJobStatusDone || record.Status == ocrJobStatusFailed { + return record, nil + } + + config, err := r.GetQrCodeOCRConfig(ctx) + if err != nil { + return nil, err + } + + statusURL := strings.TrimRight(config.JobURL, "/") + "/" + record.PaddleJobID + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + req.Header.Set("Authorization", "bearer "+config.Token) + + resp, err := ocrHTTPClient.Do(req) + if err != nil { + // 网络错误不更新 Redis,保留当前状态让前端重试 + return record, nil + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return record, nil + } + + var statusPayload paddleOCRJobStatusResponse + if err := json.Unmarshal(respBody, &statusPayload); err != nil { + return record, nil + } + + switch statusPayload.Data.State { + case "pending", "running": + record.Status = ocrJobStatusRunning + _ = r.saveOCRJobRecord(ctx, jobID, record) + return record, nil + + case "done": + if statusPayload.Data.ResultURL.JSONURL == "" { + record.Status = ocrJobStatusFailed + record.Error = "未返回识别结果地址" + _ = r.saveOCRJobRecord(ctx, jobID, record) + return record, nil + } + rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) + if err != nil { + record.Status = ocrJobStatusFailed + record.Error = err.Error() + _ = r.saveOCRJobRecord(ctx, jobID, record) + return record, nil + } + candidates := parseQrCodeGroupNameCandidates(rawText) + groupName := "" + if len(candidates) > 0 { + groupName = candidates[0] + } + record.Status = ocrJobStatusDone + record.Result = &QrCodeOCRResult{ + GroupName: groupName, + Candidates: candidates, + RawText: rawText, + } + _ = r.saveOCRJobRecord(ctx, jobID, record) + return record, nil + + case "failed": + record.Status = ocrJobStatusFailed + if statusPayload.Data.ErrorMsg != "" { + record.Error = statusPayload.Data.ErrorMsg + } else { + record.Error = "PaddleOCR 识别失败" + } + _ = r.saveOCRJobRecord(ctx, jobID, record) + return record, nil + } + + return record, nil +} + func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -130,7 +308,7 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, req.Header.Set("Authorization", "bearer "+config.Token) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := http.DefaultClient.Do(req) + resp, err := ocrHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } @@ -150,13 +328,13 @@ func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, } func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) { - for i := 0; i < qrCodeOCRMaxPollTimes; i++ { + for i := 0; i < 12; i++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil) if err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } req.Header.Set("Authorization", "bearer "+config.Token) - resp, err := http.DefaultClient.Do(req) + resp, err := ocrHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } @@ -183,8 +361,8 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string } select { case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(qrCodeOCRPollInterval): + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err()) + case <-time.After(2 * time.Second): } } return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) @@ -195,7 +373,7 @@ func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { if err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } - resp, err := http.DefaultClient.Do(req) + resp, err := ocrHTTPClient.Do(req) if err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index a04199c..ce7e065 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -41,7 +41,7 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB { func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) rows := []model.SystemConfig{ {Key: qrCodeOCRTokenConfigKey, Value: "test-token"}, {Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"}, @@ -68,7 +68,7 @@ func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) { func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) conversation := model.ChatConversation{ Title: "账号群 L202606180001", Type: ConversationTypeListingGroup, @@ -106,7 +106,7 @@ func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { func TestListQrCodesFilters(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Now() expiredAt := now.Add(-time.Hour) validAt := now.Add(7 * 24 * time.Hour) @@ -218,7 +218,7 @@ func TestParseQrCodeGroupNameCandidates(t *testing.T) { func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC) conversationID := uint64(1001) qrcode := model.ChatQrCode{ @@ -249,7 +249,7 @@ func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) conversationID := uint64(1002) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/disabled.png", @@ -270,7 +270,7 @@ func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) { func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/unused.png", Status: QrCodeStatusUnused, @@ -296,7 +296,7 @@ func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) { func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) qrcode := model.ChatQrCode{ ImageURL: "/api/files/object?key=qrcode/unused.png", Status: QrCodeStatusUnused, @@ -329,7 +329,7 @@ func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC) conversationID := uint64(1003) qrcode := model.ChatQrCode{ @@ -358,7 +358,7 @@ func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) { func TestBatchDeleteQrCodes(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC) conversationID := uint64(1004) qrcodes := []model.ChatQrCode{ @@ -427,7 +427,7 @@ func TestEnsureListingConversationCreatesQrCodeDeliveryTaskWhenStockEmpty(t *tes func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) { db := setupQrCodeTestDB(t) - repo := NewRepository(db, nil) + repo := NewRepository(db, nil, nil) conversation := model.ChatConversation{ Title: "账号群 L202606260002", Type: ConversationTypeListingGroup, diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 21d73e1..79bff37 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -2,6 +2,7 @@ package chat import ( "errors" + "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" "hfb_sys/backend/internal/model" @@ -10,16 +11,17 @@ import ( ) type Repository struct { - db *gorm.DB - hub *chathub.Hub + db *gorm.DB + hub *chathub.Hub + redis *redis.Client } const ( defaultSupportRoleCode = "cs" ) -func NewRepository(db *gorm.DB, hub *chathub.Hub) *Repository { - return &Repository{db: db, hub: hub} +func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository { + return &Repository{db: db, hub: hub, redis: redis} } func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { var existing model.ChatConversation diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a87d222..7823ce7 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -173,7 +173,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } var chatRepo *chat.Repository if deps.DB != nil { - chatRepo = chat.NewRepository(deps.DB, chatHub) + chatRepo = chat.NewRepository(deps.DB, chatHub, deps.Redis) } // 创建 chat 适配器用于 listing @@ -598,6 +598,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/qrcodes", requirePerm("qrcode:view"), chatHandler.ListQrCodesHandler) adminRoutes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetQrCodeStatsHandler) adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), chatHandler.RecognizeQrCodeGroupNameHandler) + adminRoutes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), chatHandler.SubmitOCRJobHandler) + adminRoutes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), chatHandler.GetOCRJobResultHandler) adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.UpdateQrCodeHandler) adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler) diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts index 7e0e3d7..da6494b 100644 --- a/frontend/src/features/admin/api/adminQrCodes.ts +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -34,6 +34,13 @@ export interface QrCodeOcrResult { raw_text: string } +export interface OCRJobRecord { + paddle_job_id: string + status: 'submitted' | 'running' | 'done' | 'failed' + result: QrCodeOcrResult | null + error: string +} + export interface QrCodeListQuery { status?: QrCodeStatus keyword?: string @@ -121,6 +128,28 @@ export async function recognizeQrCodeGroupName(file: File) { return data.data } +export async function submitOCRJob(file: File) { + const form = new FormData() + form.append('file', file) + const { data } = await apiClient.post>( + '/admin/chats/qrcodes/ocr-group-name/async', + form, + { + headers: { 'Content-Type': 'multipart/form-data' }, + silent: true, + } + ) + return data.data +} + +export async function getOCRJobResult(jobId: string) { + const { data } = await apiClient.get>( + `/admin/chats/qrcodes/ocr-group-name/async/${jobId}`, + { silent: true } + ) + return data.data +} + export async function updateQrCode(id: number, payload: UpdateQrCodePayload) { const { data } = await apiClient.patch>( `/admin/chats/qrcodes/${id}`, diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index 0b01599..2a104aa 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -17,7 +17,8 @@ import { deleteQrCode, fetchQrCodeStats, fetchQrCodes, - recognizeQrCodeGroupName, + getOCRJobResult, + submitOCRJob, updateQrCode, type ChatQrCode, type CreateQrCodePayload, @@ -230,8 +231,19 @@ function parseGroupNameFromOcrText(text: string) { } async function recognizeGroupName(file: File) { - const result = await recognizeQrCodeGroupName(file) - return result.group_name || parseGroupNameFromOcrText(result.raw_text || '') + const { job_id } = await submitOCRJob(file) + const delays = [1000, 2000, 3000, 3000, 3000] + for (let i = 0; i < delays.length; i++) { + await new Promise(resolve => setTimeout(resolve, delays[i])) + const record = await getOCRJobResult(job_id) + if (record.status === 'done' && record.result) { + return record.result.group_name || parseGroupNameFromOcrText(record.result.raw_text || '') + } + if (record.status === 'failed') { + throw new Error(record.error || 'OCR 识别失败') + } + } + throw new Error('OCR 识别超时') } // 上传:逐个上传图片拿 URL