完善二维码池 OCR 配置与识别
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("企微改名标记未保存")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ type ConfigDTO struct {
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Value string `json:"value" binding:"required"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user