修复 ocr 错误
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -10,16 +11,17 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user