修复 ocr 错误

This commit is contained in:
yml2213
2026-06-28 15:00:54 +08:00
parent 18eba36ef9
commit b124c74a46
8 changed files with 331 additions and 44 deletions
@@ -33,7 +33,7 @@ func setupConversationTestDB(t *testing.T) *gorm.DB {
func TestFindOrderConversationPrefersListingGroup(t *testing.T) { func TestFindOrderConversationPrefersListingGroup(t *testing.T) {
db := setupConversationTestDB(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) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
owner := model.User{Phone: "13800001001", Nickname: "号主"} owner := model.User{Phone: "13800001001", Nickname: "号主"}
@@ -96,7 +96,7 @@ func TestFindOrderConversationPrefersListingGroup(t *testing.T) {
func TestFindOrderConversationFallsBackToOrderGroup(t *testing.T) { func TestFindOrderConversationFallsBackToOrderGroup(t *testing.T) {
db := setupConversationTestDB(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) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
owner := model.User{Phone: "13800001003", Nickname: "号主"} owner := model.User{Phone: "13800001003", Nickname: "号主"}
@@ -96,7 +96,7 @@ func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": stats}) c.JSON(http.StatusOK, gin.H{"data": stats})
} }
// RecognizeQrCodeGroupNameHandler 调用 PaddleOCR API 识别二维码图片中的企业微信群名 // RecognizeQrCodeGroupNameHandler 同步调用 PaddleOCR API 识别二维码图片中的企业微信群名(保留向后兼容)
func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) { func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
file, header, err := c.Request.FormFile("file") file, header, err := c.Request.FormFile("file")
if err != nil { if err != nil {
@@ -131,6 +131,70 @@ func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": result}) 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 更新二维码 // UpdateQrCodeHandler 更新二维码
func (h *Handler) UpdateQrCodeHandler(c *gin.Context) { func (h *Handler) UpdateQrCodeHandler(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+200 -22
View File
@@ -15,18 +15,30 @@ import (
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/google/uuid"
) )
const ( const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024 maxQrCodeOCRFileSize = 10 * 1024 * 1024
qrCodeOCRPollInterval = 2 * time.Second
qrCodeOCRMaxPollTimes = 45 ocrJobRedisKeyPrefix = "ocr:job:"
ocrJobRedisTTL = 10 * time.Minute
ocrJobStatusSubmitted = "submitted"
ocrJobStatusRunning = "running"
ocrJobStatusDone = "done"
ocrJobStatusFailed = "failed"
) )
var ( var (
ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置") ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置")
ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片") ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片")
ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用") ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用")
ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期")
ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis")
ocrHTTPClient = &http.Client{Timeout: 30 * time.Second}
) )
type QrCodeOCRResult struct { type QrCodeOCRResult struct {
@@ -35,6 +47,13 @@ type QrCodeOCRResult struct {
RawText string `json:"raw_text"` 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 { type paddleOCRJobResponse struct {
Data struct { Data struct {
JobID string `json:"jobId"` JobID string `json:"jobId"`
@@ -55,7 +74,51 @@ type paddleOCRJobStatusResponse struct {
Error string `json:"error"` 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) { func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, contentType string, reader io.Reader) (*QrCodeOCRResult, error) {
config, err := r.GetQrCodeOCRConfig(ctx) config, err := r.GetQrCodeOCRConfig(ctx)
if err != nil { if err != nil {
@@ -64,22 +127,16 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
if strings.TrimSpace(config.Token) == "" { if strings.TrimSpace(config.Token) == "" {
return nil, ErrQrCodeOCRNotConfigured return nil, ErrQrCodeOCRNotConfigured
} }
data, err := io.ReadAll(io.LimitReader(reader, maxQrCodeOCRFileSize+1)) data, contentType, err := readAndValidateOCRFile(reader, contentType)
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)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -99,6 +156,127 @@ func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, con
}, nil }, 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) { func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) 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("Authorization", "bearer "+config.Token)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req) resp, err := ocrHTTPClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.JobURL, "/")+"/"+jobID, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
req.Header.Set("Authorization", "bearer "+config.Token) req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := http.DefaultClient.Do(req) resp, err := ocrHTTPClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
@@ -183,8 +361,8 @@ func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return "", ctx.Err() return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(qrCodeOCRPollInterval): case <-time.After(2 * time.Second):
} }
} }
return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable)
@@ -195,7 +373,7 @@ func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
resp, err := http.DefaultClient.Do(req) resp, err := ocrHTTPClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
} }
+10 -10
View File
@@ -41,7 +41,7 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB {
func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) { func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
rows := []model.SystemConfig{ rows := []model.SystemConfig{
{Key: qrCodeOCRTokenConfigKey, Value: "test-token"}, {Key: qrCodeOCRTokenConfigKey, Value: "test-token"},
{Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"}, {Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"},
@@ -68,7 +68,7 @@ func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) {
func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
conversation := model.ChatConversation{ conversation := model.ChatConversation{
Title: "账号群 L202606180001", Title: "账号群 L202606180001",
Type: ConversationTypeListingGroup, Type: ConversationTypeListingGroup,
@@ -106,7 +106,7 @@ func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) {
func TestListQrCodesFilters(t *testing.T) { func TestListQrCodesFilters(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
now := time.Now() now := time.Now()
expiredAt := now.Add(-time.Hour) expiredAt := now.Add(-time.Hour)
validAt := now.Add(7 * 24 * time.Hour) validAt := now.Add(7 * 24 * time.Hour)
@@ -218,7 +218,7 @@ func TestParseQrCodeGroupNameCandidates(t *testing.T) {
func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) {
db := setupQrCodeTestDB(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) now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
conversationID := uint64(1001) conversationID := uint64(1001)
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
@@ -249,7 +249,7 @@ func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) {
func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) { func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
conversationID := uint64(1002) conversationID := uint64(1002)
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/disabled.png", ImageURL: "/api/files/object?key=qrcode/disabled.png",
@@ -270,7 +270,7 @@ func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) {
func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) { func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/unused.png", ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused, Status: QrCodeStatusUnused,
@@ -296,7 +296,7 @@ func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) {
func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/unused.png", ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused, Status: QrCodeStatusUnused,
@@ -329,7 +329,7 @@ func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) {
func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) { func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) {
db := setupQrCodeTestDB(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) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC)
conversationID := uint64(1003) conversationID := uint64(1003)
qrcode := model.ChatQrCode{ qrcode := model.ChatQrCode{
@@ -358,7 +358,7 @@ func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) {
func TestBatchDeleteQrCodes(t *testing.T) { func TestBatchDeleteQrCodes(t *testing.T) {
db := setupQrCodeTestDB(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) now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC)
conversationID := uint64(1004) conversationID := uint64(1004)
qrcodes := []model.ChatQrCode{ qrcodes := []model.ChatQrCode{
@@ -427,7 +427,7 @@ func TestEnsureListingConversationCreatesQrCodeDeliveryTaskWhenStockEmpty(t *tes
func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) { func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) {
db := setupQrCodeTestDB(t) db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil) repo := NewRepository(db, nil, nil)
conversation := model.ChatConversation{ conversation := model.ChatConversation{
Title: "账号群 L202606260002", Title: "账号群 L202606260002",
Type: ConversationTypeListingGroup, Type: ConversationTypeListingGroup,
+4 -2
View File
@@ -2,6 +2,7 @@ package chat
import ( import (
"errors" "errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"hfb_sys/backend/internal/model" "hfb_sys/backend/internal/model"
@@ -12,14 +13,15 @@ import (
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
hub *chathub.Hub hub *chathub.Hub
redis *redis.Client
} }
const ( const (
defaultSupportRoleCode = "cs" defaultSupportRoleCode = "cs"
) )
func NewRepository(db *gorm.DB, hub *chathub.Hub) *Repository { func NewRepository(db *gorm.DB, hub *chathub.Hub, redis *redis.Client) *Repository {
return &Repository{db: db, hub: hub} return &Repository{db: db, hub: hub, redis: redis}
} }
func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) {
var existing model.ChatConversation var existing model.ChatConversation
+3 -1
View File
@@ -173,7 +173,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
} }
var chatRepo *chat.Repository var chatRepo *chat.Repository
if deps.DB != nil { if deps.DB != nil {
chatRepo = chat.NewRepository(deps.DB, chatHub) chatRepo = chat.NewRepository(deps.DB, chatHub, deps.Redis)
} }
// 创建 chat 适配器用于 listing // 创建 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", requirePerm("qrcode:view"), chatHandler.ListQrCodesHandler)
adminRoutes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetQrCodeStatsHandler) 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", 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.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.UpdateQrCodeHandler)
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler) adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler)
@@ -34,6 +34,13 @@ export interface QrCodeOcrResult {
raw_text: string raw_text: string
} }
export interface OCRJobRecord {
paddle_job_id: string
status: 'submitted' | 'running' | 'done' | 'failed'
result: QrCodeOcrResult | null
error: string
}
export interface QrCodeListQuery { export interface QrCodeListQuery {
status?: QrCodeStatus status?: QrCodeStatus
keyword?: string keyword?: string
@@ -121,6 +128,28 @@ export async function recognizeQrCodeGroupName(file: File) {
return data.data return data.data
} }
export async function submitOCRJob(file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post<ApiResponse<{ job_id: string }>>(
'/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<ApiResponse<OCRJobRecord>>(
`/admin/chats/qrcodes/ocr-group-name/async/${jobId}`,
{ silent: true }
)
return data.data
}
export async function updateQrCode(id: number, payload: UpdateQrCodePayload) { export async function updateQrCode(id: number, payload: UpdateQrCodePayload) {
const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>( const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>(
`/admin/chats/qrcodes/${id}`, `/admin/chats/qrcodes/${id}`,
@@ -17,7 +17,8 @@ import {
deleteQrCode, deleteQrCode,
fetchQrCodeStats, fetchQrCodeStats,
fetchQrCodes, fetchQrCodes,
recognizeQrCodeGroupName, getOCRJobResult,
submitOCRJob,
updateQrCode, updateQrCode,
type ChatQrCode, type ChatQrCode,
type CreateQrCodePayload, type CreateQrCodePayload,
@@ -230,8 +231,19 @@ function parseGroupNameFromOcrText(text: string) {
} }
async function recognizeGroupName(file: File) { async function recognizeGroupName(file: File) {
const result = await recognizeQrCodeGroupName(file) const { job_id } = await submitOCRJob(file)
return result.group_name || parseGroupNameFromOcrText(result.raw_text || '') 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 // 上传:逐个上传图片拿 URL