修复 ocr 错误
This commit is contained in:
@@ -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: "号主"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,18 +15,30 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
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 (
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -12,14 +13,15 @@ import (
|
||||
type Repository struct {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
const { data } = await apiClient.patch<ApiResponse<ChatQrCode>>(
|
||||
`/admin/chats/qrcodes/${id}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user