From 35f2d93fdfee0a6a90a977570d12798a97e2340e Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 18 Jun 2026 15:22:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E6=B1=A0=20OCR=20=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/e2e/rental_flow_test.go | 2 +- backend/internal/model/chat.go | 2 + .../internal/modules/chat/handler_qrcode.go | 36 ++ backend/internal/modules/chat/qrcode.go | 77 +++- backend/internal/modules/chat/qrcode_ocr.go | 327 ++++++++++++++++ backend/internal/modules/chat/qrcode_test.go | 117 +++++- backend/internal/modules/systemconfig/dto.go | 2 +- .../modules/systemconfig/repository.go | 6 + .../modules/systemconfig/service_update.go | 2 +- backend/internal/router/router.go | 1 + .../000012_qrcode_group_metadata.sql | 31 ++ .../src/features/admin/api/adminQrCodes.ts | 26 ++ .../admin/components/GeneralConfigDialog.vue | 9 +- .../admin/views/AdminQrCodePoolView.vue | 358 ++++++++++++++++-- .../admin/views/AdminSystemConfigsView.vue | 74 +++- .../src/shared/utils/systemConfigOptions.ts | 3 + 16 files changed, 1025 insertions(+), 48 deletions(-) create mode 100644 backend/internal/modules/chat/qrcode_ocr.go create mode 100644 backend/migrations/000012_qrcode_group_metadata.sql diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index 2ed8e39..3439c56 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -203,7 +203,7 @@ type flowServices struct { } func newFlowServices(db *gorm.DB) flowServices { - listingRepo := listing.NewRepository(db) + listingRepo := listing.NewRepository(db, nil) walletRepo := wallet.NewRepository(db) configRepo := paymentconfig.NewRepository(db, &paymentconfig.MockEncryptor{}) var paymentRepo *payment.Repository diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 1d89bcf..e25ecff 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -60,12 +60,14 @@ func (ChatMessage) TableName() string { type ChatQrCode struct { ID uint64 `gorm:"primaryKey" json:"id"` ImageURL string `gorm:"size:512;not null" json:"image_url"` + GroupName string `gorm:"size:128;not null;default:''" json:"group_name"` Status string `gorm:"size:16;not null;default:'unused'" json:"status"` ConversationID *uint64 `gorm:"index" json:"conversation_id"` UsedAt *time.Time `json:"used_at"` ExpiresAt *time.Time `json:"expires_at"` CreatedBy uint64 `gorm:"not null" json:"created_by"` Note string `gorm:"size:255;not null;default:''" json:"note"` + WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/modules/chat/handler_qrcode.go b/backend/internal/modules/chat/handler_qrcode.go index 39fc9c7..75c5df8 100644 --- a/backend/internal/modules/chat/handler_qrcode.go +++ b/backend/internal/modules/chat/handler_qrcode.go @@ -1,6 +1,7 @@ package chat import ( + "errors" "net/http" "strconv" @@ -78,6 +79,41 @@ func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": stats}) } +// RecognizeQrCodeGroupNameHandler 调用 PaddleOCR API 识别二维码图片中的企业微信群名 +func (h *Handler) RecognizeQrCodeGroupNameHandler(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传二维码图片"}) + return + } + defer file.Close() + + result, err := h.service.repo.RecognizeQrCodeGroupName( + 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 + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": result}) +} + // 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.go b/backend/internal/modules/chat/qrcode.go index bad52be..59694da 100644 --- a/backend/internal/modules/chat/qrcode.go +++ b/backend/internal/modules/chat/qrcode.go @@ -15,6 +15,12 @@ const ( QrCodeStatusUnused = "unused" QrCodeStatusUsed = "used" QrCodeStatusDisabled = "disabled" + + qrCodeOCRTokenConfigKey = "integration.paddle_ocr_token" + qrCodeOCRJobURLConfigKey = "integration.paddle_ocr_job_url" + qrCodeOCRModelConfigKey = "integration.paddle_ocr_model" + defaultQrCodeOCRJobURL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" + defaultQrCodeOCRModel = "PaddleOCR-VL-1.6" ) var ( @@ -26,6 +32,7 @@ var ( // CreateQrCodeRequest 创建二维码请求 type CreateQrCodeRequest struct { ImageURL string `json:"image_url" binding:"required"` + GroupName string `json:"group_name"` Note string `json:"note"` ExpiresAt *time.Time `json:"expires_at"` } @@ -38,8 +45,10 @@ type BatchCreateQrCodeRequest struct { // UpdateQrCodeRequest 更新二维码请求 type UpdateQrCodeRequest struct { ImageURL *string `json:"image_url"` + GroupName *string `json:"group_name"` Note *string `json:"note"` Status *string `json:"status"` + WecomRenamed *bool `json:"wecom_renamed"` ExpiresAt *time.Time `json:"expires_at"` ClearExpiresAt bool `json:"clear_expires_at"` } @@ -59,10 +68,24 @@ type QrCodeStats struct { TotalCount int64 `json:"total_count"` } +// QrCodeListItem 二维码列表项 +type QrCodeListItem struct { + model.ChatQrCode + BoundConversationTitle string `json:"bound_conversation_title"` +} + +// QrCodeOCRConfig 二维码 OCR 调用配置 +type QrCodeOCRConfig struct { + Token string `json:"token"` + JobURL string `json:"job_url"` + Model string `json:"model"` +} + // CreateQrCode 创建二维码 func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) { qrcode := model.ChatQrCode{ ImageURL: req.ImageURL, + GroupName: req.GroupName, Status: QrCodeStatusUnused, CreatedBy: adminID, Note: req.Note, @@ -90,6 +113,7 @@ func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req for _, item := range req.Items { qr := model.ChatQrCode{ ImageURL: item.ImageURL, + GroupName: item.GroupName, Status: QrCodeStatusUnused, CreatedBy: adminID, Note: item.Note, @@ -109,7 +133,7 @@ func (r *Repository) BatchCreateQrCode(ctx context.Context, adminID uint64, req } // ListQrCodes 列表查询二维码 -func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) { +func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]QrCodeListItem, int64, error) { if req.Page < 1 { req.Page = 1 } @@ -131,9 +155,22 @@ func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([] } // 查询列表 - var qrcodes []model.ChatQrCode + var qrcodes []QrCodeListItem offset := (req.Page - 1) * req.Limit - if err := query.Order("id DESC").Offset(offset).Limit(req.Limit).Find(&qrcodes).Error; err != nil { + if err := r.db.WithContext(ctx). + Table("chat_qrcode_pool AS q"). + Select("q.*, COALESCE(c.title, '') AS bound_conversation_title"). + Joins("LEFT JOIN chat_conversations AS c ON c.id = q.conversation_id"). + Scopes(func(db *gorm.DB) *gorm.DB { + if req.Status != "" { + return db.Where("q.status = ?", req.Status) + } + return db + }). + Order("q.id DESC"). + Offset(offset). + Limit(req.Limit). + Scan(&qrcodes).Error; err != nil { return nil, 0, err } @@ -171,6 +208,34 @@ func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) { return stats, nil } +// GetQrCodeOCRConfig 获取前端直连 PaddleOCR 所需配置 +func (r *Repository) GetQrCodeOCRConfig(ctx context.Context) (*QrCodeOCRConfig, error) { + config := &QrCodeOCRConfig{ + JobURL: defaultQrCodeOCRJobURL, + Model: defaultQrCodeOCRModel, + } + var rows []model.SystemConfig + keys := []string{qrCodeOCRTokenConfigKey, qrCodeOCRJobURLConfigKey, qrCodeOCRModelConfigKey} + if err := r.db.WithContext(ctx).Where("`key` IN ?", keys).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + switch row.Key { + case qrCodeOCRTokenConfigKey: + config.Token = row.Value + case qrCodeOCRJobURLConfigKey: + if row.Value != "" { + config.JobURL = row.Value + } + case qrCodeOCRModelConfigKey: + if row.Value != "" { + config.Model = row.Value + } + } + } + return config, nil +} + // UpdateQrCode 更新二维码 func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error { var qrcode model.ChatQrCode @@ -186,9 +251,15 @@ func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCo if req.ImageURL != nil { updates["image_url"] = *req.ImageURL } + if req.GroupName != nil { + updates["group_name"] = *req.GroupName + } if req.Note != nil { updates["note"] = *req.Note } + if req.WecomRenamed != nil { + updates["wecom_renamed"] = *req.WecomRenamed + } if req.Status != nil { // 校验状态值 if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled { diff --git a/backend/internal/modules/chat/qrcode_ocr.go b/backend/internal/modules/chat/qrcode_ocr.go new file mode 100644 index 0000000..008b300 --- /dev/null +++ b/backend/internal/modules/chat/qrcode_ocr.go @@ -0,0 +1,327 @@ +package chat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" +) + +const ( + maxQrCodeOCRFileSize = 10 * 1024 * 1024 + qrCodeOCRPollInterval = 2 * time.Second + qrCodeOCRMaxPollTimes = 45 +) + +var ( + ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置") + ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片") + ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用") +) + +type QrCodeOCRResult struct { + GroupName string `json:"group_name"` + Candidates []string `json:"candidates"` + RawText string `json:"raw_text"` +} + +type paddleOCRJobResponse struct { + Data struct { + JobID string `json:"jobId"` + } `json:"data"` + Message string `json:"message"` + Error string `json:"error"` +} + +type paddleOCRJobStatusResponse struct { + Data struct { + State string `json:"state"` + ErrorMsg string `json:"errorMsg"` + ResultURL struct { + JSONURL string `json:"jsonUrl"` + } `json:"resultUrl"` + } `json:"data"` + Message string `json:"message"` + Error string `json:"error"` +} + +// RecognizeQrCodeGroupName 调用 PaddleOCR 异步 API 识别二维码图片中的群名。 +func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, contentType string, reader io.Reader) (*QrCodeOCRResult, error) { + config, err := r.GetQrCodeOCRConfig(ctx) + if err != nil { + return nil, err + } + 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) + if err != nil { + return nil, err + } + jsonURL, err := waitPaddleOCRJob(ctx, config, jobID) + if err != nil { + return nil, err + } + rawText, err := fetchPaddleOCRText(ctx, jsonURL) + if err != nil { + return nil, err + } + candidates := parseQrCodeGroupNameCandidates(rawText) + groupName := "" + if len(candidates) > 0 { + groupName = candidates[0] + } + return &QrCodeOCRResult{ + GroupName: groupName, + Candidates: candidates, + RawText: rawText, + }, nil +} + +func submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + _ = writer.WriteField("model", config.Model) + optionalPayload, _ := json.Marshal(map[string]bool{ + "useDocOrientationClassify": false, + "useDocUnwarping": false, + "useChartRecognition": false, + }) + _ = writer.WriteField("optionalPayload", string(optionalPayload)) + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filename))) + partHeader.Set("Content-Type", contentType) + fileWriter, err := writer.CreatePart(partHeader) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + if _, err := fileWriter.Write(data); err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, body) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + req.Header.Set("Authorization", "bearer "+config.Token) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) + } + var payload paddleOCRJobResponse + if err := json.Unmarshal(respBody, &payload); err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + if payload.Data.JobID == "" { + return "", fmt.Errorf("%w: 未返回 jobId", ErrQrCodeOCRUnavailable) + } + return payload.Data.JobID, nil +} + +func waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) { + for i := 0; i < qrCodeOCRMaxPollTimes; 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) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) + } + var payload paddleOCRJobStatusResponse + if err := json.Unmarshal(respBody, &payload); err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + switch payload.Data.State { + case "done": + if payload.Data.ResultURL.JSONURL == "" { + return "", fmt.Errorf("%w: 未返回识别结果地址", ErrQrCodeOCRUnavailable) + } + return payload.Data.ResultURL.JSONURL, nil + case "failed": + if payload.Data.ErrorMsg != "" { + return "", fmt.Errorf("%w: %s", ErrQrCodeOCRUnavailable, payload.Data.ErrorMsg) + } + return "", ErrQrCodeOCRUnavailable + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(qrCodeOCRPollInterval): + } + } + return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) +} + +func fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + return "", fmt.Errorf("%w: status=%d body=%s", ErrQrCodeOCRUnavailable, resp.StatusCode, string(respBody)) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) + } + return collectPaddleOCRJSONLText(string(body)), nil +} + +func collectPaddleOCRJSONLText(raw string) string { + var texts []string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var value any + if err := json.Unmarshal([]byte(line), &value); err != nil { + texts = append(texts, line) + continue + } + collectPaddleOCRText(value, &texts) + } + return strings.Join(texts, "\n") +} + +func collectPaddleOCRText(value any, texts *[]string) { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) != "" { + *texts = append(*texts, v) + } + case []any: + for _, item := range v { + collectPaddleOCRText(item, texts) + } + case map[string]any: + if markdown, ok := v["markdown"].(map[string]any); ok { + if text, ok := markdown["text"].(string); ok && strings.TrimSpace(text) != "" { + *texts = append(*texts, text) + } + } + for key, item := range v { + if key == "images" || key == "outputImages" { + continue + } + collectPaddleOCRText(item, texts) + } + } +} + +func parseQrCodeGroupNameCandidates(rawText string) []string { + excluded := []*regexp.Regexp{ + regexp.MustCompile(`使用.*微信`), + regexp.MustCompile(`企业微信`), + regexp.MustCompile(`扫码`), + regexp.MustCompile(`加入`), + regexp.MustCompile(`二维码`), + regexp.MustCompile(`有效`), + regexp.MustCompile(`天内`), + regexp.MustCompile(`月\d+日`), + regexp.MustCompile(`前`), + regexp.MustCompile(`^\d+$`), + } + scores := map[string]int{} + for _, line := range strings.Split(rawText, "\n") { + line = normalizeQrCodeOCRLine(line) + if line == "" || utf8.RuneCountInString(line) < 3 || utf8.RuneCountInString(line) > 64 { + continue + } + if !regexp.MustCompile(`[\p{Han}]`).MatchString(line) { + continue + } + skip := false + for _, pattern := range excluded { + if pattern.MatchString(line) { + skip = true + break + } + } + if skip { + continue + } + scores[line] = scoreQrCodeGroupNameCandidate(line) + } + candidates := make([]string, 0, len(scores)) + for candidate := range scores { + candidates = append(candidates, candidate) + } + sort.SliceStable(candidates, func(i, j int) bool { + return scores[candidates[i]] > scores[candidates[j]] + }) + return candidates +} + +func normalizeQrCodeOCRLine(line string) string { + replacer := strings.NewReplacer(" ", "", "\t", "", "|", "", "|", "", "#", "", "*", "", "`", "", ">", "") + line = replacer.Replace(line) + allowed := regexp.MustCompile(`[^\p{Han}a-zA-Z0-9_()()《》\-—·]+`) + return allowed.ReplaceAllString(line, "") +} + +func scoreQrCodeGroupNameCandidate(line string) int { + length := utf8.RuneCountInString(line) + score := length + if score > 20 { + score = 20 + } + if strings.Contains(line, "群") { + score += 100 + } + if strings.Contains(line, "-") || strings.Contains(line, "—") { + score += 30 + } + if length >= 6 && length <= 24 { + score += 20 + } + return score +} + +func escapeQuotes(value string) string { + return strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace(value) +} diff --git a/backend/internal/modules/chat/qrcode_test.go b/backend/internal/modules/chat/qrcode_test.go index a8421b2..d3faf2e 100644 --- a/backend/internal/modules/chat/qrcode_test.go +++ b/backend/internal/modules/chat/qrcode_test.go @@ -2,6 +2,7 @@ package chat import ( "errors" + "strings" "testing" "time" @@ -20,12 +21,93 @@ func setupQrCodeTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("无法创建测试数据库: %v", err) } - if err := db.AutoMigrate(&model.ChatQrCode{}); err != nil { + if err := db.AutoMigrate(&model.ChatQrCode{}, &model.ChatConversation{}, &model.SystemConfig{}); err != nil { t.Fatalf("数据库迁移失败: %v", err) } return db } +func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, nil) + rows := []model.SystemConfig{ + {Key: qrCodeOCRTokenConfigKey, Value: "test-token"}, + {Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"}, + {Key: qrCodeOCRModelConfigKey, Value: "PaddleOCR-Test"}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("创建系统配置失败: %v", err) + } + + config, err := repo.GetQrCodeOCRConfig(t.Context()) + if err != nil { + t.Fatalf("读取 OCR 配置失败: %v", err) + } + if config.Token != "test-token" { + t.Fatalf("token = %q", config.Token) + } + if config.JobURL != "https://example.test/ocr/jobs" { + t.Fatalf("job_url = %q", config.JobURL) + } + if config.Model != "PaddleOCR-Test" { + t.Fatalf("model = %q", config.Model) + } +} + +func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, nil) + conversation := model.ChatConversation{ + Title: "账号群 L202606180001", + Type: ConversationTypeListingGroup, + Status: "active", + } + if err := db.Create(&conversation).Error; err != nil { + t.Fatalf("创建群聊失败: %v", err) + } + + qrcode := model.ChatQrCode{ + ImageURL: "/api/files/object?key=qrcode/group.png", + GroupName: "王大锤-鼠鼠跑刀一群", + Status: QrCodeStatusUsed, + ConversationID: &conversation.ID, + CreatedBy: 1, + } + if err := db.Create(&qrcode).Error; err != nil { + t.Fatalf("创建二维码失败: %v", err) + } + + items, total, err := repo.ListQrCodes(t.Context(), QrCodeListRequest{Page: 1, Limit: 20}) + if err != nil { + t.Fatalf("查询二维码失败: %v", err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("total = %d, len = %d, want 1", total, len(items)) + } + if items[0].GroupName != "王大锤-鼠鼠跑刀一群" { + t.Fatalf("群名 = %q", items[0].GroupName) + } + if items[0].BoundConversationTitle != "账号群 L202606180001" { + t.Fatalf("绑定群名 = %q", items[0].BoundConversationTitle) + } +} + +func TestParseQrCodeGroupNameCandidates(t *testing.T) { + rawText := strings.Join([]string{ + "使用微信或企业微信扫码加入", + "王大锤-鼠鼠跑刀一群", + "这二维码7天内有效", + }, "\n") + + candidates := parseQrCodeGroupNameCandidates(rawText) + if len(candidates) == 0 { + t.Fatal("未解析出群名候选") + } + if candidates[0] != "王大锤-鼠鼠跑刀一群" { + t.Fatalf("首个候选 = %q", candidates[0]) + } +} + func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) { db := setupQrCodeTestDB(t) repo := NewRepository(db, nil) @@ -103,3 +185,36 @@ func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) { t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusDisabled) } } + +func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) { + db := setupQrCodeTestDB(t) + repo := NewRepository(db, nil) + qrcode := model.ChatQrCode{ + ImageURL: "/api/files/object?key=qrcode/unused.png", + Status: QrCodeStatusUnused, + CreatedBy: 1, + } + if err := db.Create(&qrcode).Error; err != nil { + t.Fatalf("创建二维码失败: %v", err) + } + + groupName := "王大锤-鼠鼠跑刀一群" + renamed := true + if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{ + GroupName: &groupName, + WecomRenamed: &renamed, + }); err != nil { + t.Fatalf("更新二维码失败: %v", err) + } + + var saved model.ChatQrCode + if err := db.First(&saved, qrcode.ID).Error; err != nil { + t.Fatalf("查询二维码失败: %v", err) + } + if saved.GroupName != groupName { + t.Fatalf("群名 = %q, want %q", saved.GroupName, groupName) + } + if !saved.WecomRenamed { + t.Fatal("企微改名标记未保存") + } +} diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 567c9ab..769a934 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -13,7 +13,7 @@ type ConfigDTO struct { } type UpdateRequest struct { - Value string `json:"value" binding:"required"` + Value string `json:"value"` Description string `json:"description"` } diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 7223039..0f51bde 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -39,6 +39,9 @@ var defaultConfigs = []defaultConfig{ {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, {Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"}, + {Key: "integration.paddle_ocr_token", Value: "", Description: "PaddleOCR API Token,用于二维码群名自动识别"}, + {Key: "integration.paddle_ocr_job_url", Value: "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", Description: "PaddleOCR 异步任务接口地址"}, + {Key: "integration.paddle_ocr_model", Value: "PaddleOCR-VL-1.6", Description: "PaddleOCR 识别模型"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, @@ -53,6 +56,9 @@ var adminVisibleConfigKeys = []string{ "handoff.owner_return_confirm_timeout_minutes", "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", + "integration.paddle_ocr_job_url", + "integration.paddle_ocr_model", + "integration.paddle_ocr_token", "listing.publish_agreements", "listing.publish_options", "listing.review_required", diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 4ca2df5..317a3a8 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -8,7 +8,7 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up if s.repo == nil { return nil, ErrDependencyUnavailable } - if key == "" || req.Value == "" { + if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") { return nil, ErrInvalidConfig } return s.repo.Update(ctx, actorID, key, req, meta) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a9857ce..e731bea 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -567,6 +567,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/chats/qrcodes/batch", requirePerm("chat:manage"), chatHandler.BatchCreateQrCodeHandler) adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler) adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler) + adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("chat:manage"), chatHandler.RecognizeQrCodeGroupNameHandler) adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler) adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler) diff --git a/backend/migrations/000012_qrcode_group_metadata.sql b/backend/migrations/000012_qrcode_group_metadata.sql new file mode 100644 index 0000000..32ae153 --- /dev/null +++ b/backend/migrations/000012_qrcode_group_metadata.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE chat_qrcode_pool + ADD COLUMN group_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '企业微信群名' AFTER image_url, + ADD COLUMN wecom_renamed TINYINT(1) NOT NULL DEFAULT 0 COMMENT '企业微信侧已改名标记' AFTER note; + +INSERT INTO system_configs (`key`, `value`, description) VALUES +('integration.paddle_ocr_token', '', 'PaddleOCR API Token,用于二维码群名自动识别'), +('integration.paddle_ocr_job_url', 'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs', 'PaddleOCR 异步任务接口地址'), +('integration.paddle_ocr_model', 'PaddleOCR-VL-1.6', 'PaddleOCR 识别模型') +ON DUPLICATE KEY UPDATE + description = VALUES(description); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE chat_qrcode_pool + DROP COLUMN wecom_renamed, + DROP COLUMN group_name; + +DELETE FROM system_configs +WHERE `key` IN ( + 'integration.paddle_ocr_token', + 'integration.paddle_ocr_job_url', + 'integration.paddle_ocr_model' +); + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/api/adminQrCodes.ts b/frontend/src/features/admin/api/adminQrCodes.ts index 70f4503..5803b75 100644 --- a/frontend/src/features/admin/api/adminQrCodes.ts +++ b/frontend/src/features/admin/api/adminQrCodes.ts @@ -6,12 +6,15 @@ export type QrCodeStatus = 'unused' | 'used' | 'disabled' export interface ChatQrCode { id: number image_url: string + group_name: string status: QrCodeStatus conversation_id: number | null + bound_conversation_title: string used_at: string | null expires_at: string | null created_by: number note: string + wecom_renamed: boolean created_at: string updated_at: string } @@ -23,6 +26,12 @@ export interface QrCodeStats { total_count: number } +export interface QrCodeOcrResult { + group_name: string + candidates: string[] + raw_text: string +} + export interface QrCodeListQuery { status?: QrCodeStatus page?: number @@ -31,14 +40,17 @@ export interface QrCodeListQuery { export interface CreateQrCodePayload { image_url: string + group_name?: string note?: string expires_at?: string | null } export interface UpdateQrCodePayload { image_url?: string + group_name?: string note?: string status?: QrCodeStatus + wecom_renamed?: boolean expires_at?: string | null clear_expires_at?: boolean } @@ -73,6 +85,20 @@ export async function batchCreateQrCodes(items: CreateQrCodePayload[]) { return data.data } +export async function recognizeQrCodeGroupName(file: File) { + const form = new FormData() + form.append('file', file) + const { data } = await apiClient.post>( + '/admin/chats/qrcodes/ocr-group-name', + form, + { + headers: { 'Content-Type': 'multipart/form-data' }, + 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/components/GeneralConfigDialog.vue b/frontend/src/features/admin/components/GeneralConfigDialog.vue index 61486a2..a995944 100644 --- a/frontend/src/features/admin/components/GeneralConfigDialog.vue +++ b/frontend/src/features/admin/components/GeneralConfigDialog.vue @@ -39,6 +39,8 @@ const isStructuredConfig = computed(() => { return trimmed.startsWith('{') || trimmed.startsWith('[') }) +const isSecretConfig = computed(() => props.config.key.includes('token')) + const selectOptions = computed(() => { const options = getSystemConfigSelectOptions(props.config.key) if (!options) return null @@ -92,7 +94,12 @@ async function handleSave() { - + diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index 6f4db24..bbb3833 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -1,13 +1,14 @@