完善二维码池 OCR 配置与识别

This commit is contained in:
yml
2026-06-18 15:22:46 +08:00
parent e8bdf574f4
commit 35f2d93fdf
16 changed files with 1025 additions and 48 deletions
@@ -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)
+74 -3
View File
@@ -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 {
+327
View File
@@ -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)
}
+116 -1
View File
@@ -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("企微改名标记未保存")
}
}
+1 -1
View File
@@ -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)