package chat import ( "bytes" "context" "encoding/json" "errors" "fmt" "image" "image/color" "image/draw" _ "image/gif" "image/jpeg" _ "image/png" "io" "math" "mime/multipart" "net/http" "net/textproto" "regexp" "sort" "strconv" "strings" "sync" "time" "unicode/utf8" "hfb_sys/backend/internal/logging" "github.com/google/uuid" "go.uber.org/zap" xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) const ( maxQrCodeOCRFileSize = 10 * 1024 * 1024 maxQrCodeOCRPixels = 40_000_000 maxQrCodeOCRSide = 2560 qrCodeOCRJPEGQuality = 92 ocrRequestConcurrency = 2 ocrRequestMinInterval = 250 * time.Millisecond ocrRequestMaxAttempts = 3 ocrRetryBaseDelay = 500 * time.Millisecond ocrResponseBodyLimit = 2 * 1024 * 1024 ocrResultBodyLimit = 10 * 1024 * 1024 ocrLogBodyLimit = 2048 ocrJobRedisKeyPrefix = "ocr:job:" ocrJobRedisTTL = 10 * time.Minute ocrJobStatusSubmitted = "submitted" ocrJobStatusRunning = "running" ocrJobStatusDone = "done" ocrJobStatusFailed = "failed" ) var ( ErrQrCodeOCRNotConfigured = errors.New("二维码 OCR Token 未配置") ErrQrCodeOCRInvalidFile = errors.New("无效的二维码图片") ErrQrCodeOCRUnavailable = errors.New("二维码 OCR 服务不可用") ErrQrCodeOCRJobNotFound = errors.New("OCR 任务不存在或已过期") ErrQrCodeOCRRedisDisabled = errors.New("OCR 异步模式需要 Redis") ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} sharedOCRRequestGate = newOCRRequestGate(ocrRequestConcurrency, ocrRequestMinInterval) ) type ocrRequestGate struct { slots chan struct{} mu sync.Mutex nextAllowed time.Time minInterval time.Duration } func newOCRRequestGate(concurrency int, minInterval time.Duration) *ocrRequestGate { if concurrency < 1 { concurrency = 1 } return &ocrRequestGate{ slots: make(chan struct{}, concurrency), minInterval: max(0, minInterval), } } func (g *ocrRequestGate) acquire(ctx context.Context, sleep func(context.Context, time.Duration) error) (func(), error) { select { case g.slots <- struct{}{}: case <-ctx.Done(): return nil, ctx.Err() } release := func() { <-g.slots } g.mu.Lock() now := time.Now() wait := max(time.Duration(0), g.nextAllowed.Sub(now)) startAt := now.Add(wait) g.nextAllowed = startAt.Add(g.minInterval) g.mu.Unlock() if wait > 0 { if err := sleep(ctx, wait); err != nil { release() return nil, err } } return release, nil } type ocrUpstreamError struct { Operation string StatusCode int Code string Message string TraceID string RetryAfter time.Duration BodySnippet string Cause error Retriable bool } func (e *ocrUpstreamError) Error() string { parts := []string{ErrQrCodeOCRUnavailable.Error(), "operation=" + e.Operation} if e.StatusCode != 0 { parts = append(parts, "status="+strconv.Itoa(e.StatusCode)) } if e.Code != "" { parts = append(parts, "code="+e.Code) } if e.Message != "" { parts = append(parts, "message="+e.Message) } if e.TraceID != "" { parts = append(parts, "trace_id="+e.TraceID) } if e.Cause != nil { parts = append(parts, "cause="+e.Cause.Error()) } return strings.Join(parts, " ") } func (e *ocrUpstreamError) Unwrap() error { return ErrQrCodeOCRUnavailable } type QrCodeOCRResult struct { GroupName string `json:"group_name"` Candidates []string `json:"candidates"` RawText string `json:"raw_text"` } type OCRJobRecord struct { PaddleJobID string `json:"paddle_job_id"` Status string `json:"status"` Result *QrCodeOCRResult `json:"result,omitempty"` Error string `json:"error,omitempty"` } type paddleOCRJobResponse struct { Data struct { JobID string `json:"jobId"` } `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"` } 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 } contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) if contentType == "" || !strings.HasPrefix(contentType, "image/") { contentType = strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) } if !strings.HasPrefix(contentType, "image/") { return nil, "", ErrQrCodeOCRInvalidFile } config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxQrCodeOCRPixels { return nil, "", ErrQrCodeOCRInvalidFile } source, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, "", ErrQrCodeOCRInvalidFile } normalized := resizeOCRImage(source, maxQrCodeOCRSide) var buffer bytes.Buffer if err := jpeg.Encode(&buffer, normalized, &jpeg.Options{Quality: qrCodeOCRJPEGQuality}); err != nil { return nil, "", ErrQrCodeOCRInvalidFile } if buffer.Len() == 0 || buffer.Len() > maxQrCodeOCRFileSize { return nil, "", ErrQrCodeOCRInvalidFile } return buffer.Bytes(), "image/jpeg", nil } func resizeOCRImage(source image.Image, maxSide int) image.Image { bounds := source.Bounds() width := bounds.Dx() height := bounds.Dy() if width <= 0 || height <= 0 { return source } scale := math.Min(1, float64(maxSide)/float64(max(width, height))) targetWidth := max(1, int(math.Round(float64(width)*scale))) targetHeight := max(1, int(math.Round(float64(height)*scale))) target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight)) draw.Draw(target, target.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) xdraw.ApproxBiLinear.Scale(target, target.Bounds(), source, bounds, draw.Over, nil) return target } func ocrJobKey(jobID string) string { return ocrJobRedisKeyPrefix + jobID } func (r *Repository) saveOCRJobRecord(ctx context.Context, jobID string, record *OCRJobRecord) error { if r.redis == nil { return ErrQrCodeOCRRedisDisabled } raw, err := json.Marshal(record) if err != nil { return err } return r.redis.Set(ctx, ocrJobKey(jobID), string(raw), ocrJobRedisTTL).Err() } func (r *Repository) getOCRJobRecord(ctx context.Context, jobID string) (*OCRJobRecord, error) { if r.redis == nil { return nil, ErrQrCodeOCRRedisDisabled } raw, err := r.redis.Get(ctx, ocrJobKey(jobID)).Result() if err != nil { return nil, ErrQrCodeOCRJobNotFound } var record OCRJobRecord if err := json.Unmarshal([]byte(raw), &record); err != nil { return nil, err } return &record, nil } // RecognizeQrCodeGroupName 同步调用 PaddleOCR 识别二维码图片中的群名(保留向后兼容)。 func (r *Repository) RecognizeQrCodeGroupName(ctx context.Context, filename, contentType string, reader io.Reader) (*QrCodeOCRResult, error) { config, err := r.GetQrCodeOCRConfig(ctx) if err != nil { return nil, err } if strings.TrimSpace(config.Token) == "" { return nil, ErrQrCodeOCRNotConfigured } data, contentType, err := readAndValidateOCRFile(reader, contentType) if err != nil { return nil, err } paddleJobID, err := r.submitPaddleOCRJob(ctx, config, filename, contentType, data) if err != nil { return nil, err } jsonURL, err := r.waitPaddleOCRJob(ctx, config, paddleJobID) if err != nil { return nil, err } rawText, err := r.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 } // 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 := r.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 respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) if err == nil { req.Header.Set("Authorization", "bearer "+config.Token) } return req, err }) if err != nil { // 保留当前状态,让下一次前端轮询继续尝试。 return record, nil } var statusPayload paddleOCRJobStatusResponse if err := json.Unmarshal(respBody, &statusPayload); err != nil { upstreamErr := newOCRUpstreamError("status_decode", http.StatusOK, nil, respBody, err) r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) return record, nil } switch statusPayload.Data.State { case "pending", "running": record.Status = ocrJobStatusRunning r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil case "done": if statusPayload.Data.ResultURL.JSONURL == "" { record.Status = ocrJobStatusFailed record.Error = "未返回识别结果地址" r.ocrLogger(ctx).Error("PaddleOCR 任务缺少结果地址", zap.String("operation", "status"), zap.String("paddle_job_id", record.PaddleJobID), zap.String("upstream_state", statusPayload.Data.State), ) r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil } rawText, err := r.fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL) if err != nil { record.Status = ocrJobStatusFailed record.Error = err.Error() r.saveOCRJobRecordWithLog(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.saveOCRJobRecordWithLog(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.ocrLogger(ctx).Error("PaddleOCR 任务执行失败", zap.String("operation", "status"), zap.String("paddle_job_id", record.PaddleJobID), zap.String("upstream_state", statusPayload.Data.State), zap.String("upstream_message", truncateOCRLogValue(record.Error, 512)), ) r.saveOCRJobRecordWithLog(ctx, jobID, record) return record, nil default: upstreamErr := newOCRUpstreamError("status_payload", http.StatusOK, nil, respBody, errors.New("unexpected OCR job state")) r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) } return record, nil } func (r *Repository) saveOCRJobRecordWithLog(ctx context.Context, jobID string, record *OCRJobRecord) { if err := r.saveOCRJobRecord(ctx, jobID, record); err != nil { r.ocrLogger(ctx).Error("OCR 任务状态保存失败", zap.String("ocr_job_id", jobID), zap.String("paddle_job_id", record.PaddleJobID), zap.String("ocr_status", record.Status), zap.Error(err), ) } } func (r *Repository) submitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, filename, contentType string, data []byte) (string, error) { filename = normalizeOCRFilename(filename) 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) } requestBody := append([]byte(nil), body.Bytes()...) formContentType := writer.FormDataContentType() respBody, err := r.doPaddleRequest(ctx, "submit", func(ctx context.Context) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.JobURL, bytes.NewReader(requestBody)) if err == nil { req.Header.Set("Authorization", "bearer "+config.Token) req.Header.Set("Content-Type", formContentType) } return req, err }) if err != nil { return "", err } var payload paddleOCRJobResponse if err := json.Unmarshal(respBody, &payload); err != nil { upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, err) r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) return "", upstreamErr } if payload.Data.JobID == "" { upstreamErr := newOCRUpstreamError("submit", http.StatusOK, nil, respBody, errors.New("missing jobId")) r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) return "", upstreamErr } r.ocrLogger(ctx).Info("PaddleOCR 任务提交成功", zap.String("operation", "submit"), zap.String("paddle_job_id", payload.Data.JobID), zap.String("model", config.Model), zap.String("file_content_type", contentType), zap.Int("file_size", len(data)), ) return payload.Data.JobID, nil } func normalizeOCRFilename(filename string) string { filename = strings.TrimSpace(filename) if filename == "" { return "qrcode.jpg" } if dot := strings.LastIndex(filename, "."); dot > 0 { filename = filename[:dot] } return filename + ".jpg" } func (r *Repository) waitPaddleOCRJob(ctx context.Context, config *QrCodeOCRConfig, jobID string) (string, error) { for i := 0; i < 12; i++ { statusURL := strings.TrimRight(config.JobURL, "/") + "/" + jobID respBody, err := r.doPaddleRequest(ctx, "status", func(ctx context.Context) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) if err == nil { req.Header.Set("Authorization", "bearer "+config.Token) } return req, err }) if err != nil { return "", err } var payload paddleOCRJobStatusResponse if err := json.Unmarshal(respBody, &payload); err != nil { upstreamErr := newOCRUpstreamError("status", http.StatusOK, nil, respBody, err) r.logOCRUpstreamFailure(ctx, upstreamErr, 1, true, 0) return "", upstreamErr } 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 } if err := r.ocrSleep(ctx, 2*time.Second); err != nil { return "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } } return "", fmt.Errorf("%w: 识别超时", ErrQrCodeOCRUnavailable) } func (r *Repository) fetchPaddleOCRText(ctx context.Context, jsonURL string) (string, error) { body, err := r.doPaddleRequest(ctx, "result", func(ctx context.Context) (*http.Request, error) { return http.NewRequestWithContext(ctx, http.MethodGet, jsonURL, nil) }) if err != nil { return "", err } return collectPaddleOCRJSONLText(string(body)), nil } type ocrRequestFactory func(context.Context) (*http.Request, error) func (r *Repository) doPaddleRequest(ctx context.Context, operation string, factory ocrRequestFactory) ([]byte, error) { var lastErr *ocrUpstreamError for attempt := 1; attempt <= ocrRequestMaxAttempts; attempt++ { release, err := r.ocrGate.acquire(ctx, r.ocrSleep) if err != nil { return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } req, err := factory(ctx) if err != nil { release() return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } startedAt := time.Now() resp, requestErr := r.ocrHTTPClient.Do(req) duration := time.Since(startedAt) if requestErr != nil { release() lastErr = newOCRUpstreamError(operation, 0, nil, nil, requestErr) lastErr.Retriable = isRetriableOCRNetworkError(ctx, requestErr) r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration) } else { bodyLimit := int64(ocrResponseBodyLimit) if operation == "result" { bodyLimit = ocrResultBodyLimit } body, readErr := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) _ = resp.Body.Close() release() if int64(len(body)) > bodyLimit { body = body[:bodyLimit] readErr = errors.New("upstream response exceeded limit") } if readErr == nil && resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return body, nil } lastErr = newOCRUpstreamError(operation, resp.StatusCode, resp.Header, body, readErr) lastErr.Retriable = isRetriableOCRStatus(resp.StatusCode) || readErr != nil r.logOCRUpstreamFailure(ctx, lastErr, attempt, attempt == ocrRequestMaxAttempts || !lastErr.Retriable, duration) } if !lastErr.Retriable || attempt == ocrRequestMaxAttempts { return nil, lastErr } delay := ocrRetryDelay(attempt, lastErr.RetryAfter) if err := r.ocrSleep(ctx, delay); err != nil { return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err) } } return nil, lastErr } func newOCRUpstreamError(operation string, statusCode int, headers http.Header, body []byte, cause error) *ocrUpstreamError { upstreamErr := &ocrUpstreamError{ Operation: operation, StatusCode: statusCode, BodySnippet: truncateOCRLogValue(string(body), ocrLogBodyLimit), Cause: cause, } if headers != nil { upstreamErr.RetryAfter = parseOCRRetryAfter(headers.Get("Retry-After"), time.Now()) upstreamErr.TraceID = firstNonEmpty( headers.Get("X-Request-ID"), headers.Get("X-Trace-ID"), headers.Get("Trace-ID"), ) } var payload map[string]any if json.Unmarshal(body, &payload) == nil { upstreamErr.Code = firstJSONText(payload, "code", "errorCode", "error_code") upstreamErr.Message = firstJSONText(payload, "message", "msg", "error", "errorMsg", "error_message") if upstreamErr.TraceID == "" { upstreamErr.TraceID = firstJSONText(payload, "traceId", "trace_id", "requestId", "request_id") } } return upstreamErr } func (r *Repository) logOCRUpstreamFailure(ctx context.Context, upstreamErr *ocrUpstreamError, attempt int, final bool, duration time.Duration) { if upstreamErr == nil { return } fields := []zap.Field{ zap.String("operation", upstreamErr.Operation), zap.Int("attempt", attempt), zap.Bool("final", final), zap.Bool("retriable", upstreamErr.Retriable), zap.Int("upstream_status", upstreamErr.StatusCode), zap.String("upstream_code", upstreamErr.Code), zap.String("upstream_message", upstreamErr.Message), zap.String("upstream_trace_id", upstreamErr.TraceID), zap.String("upstream_body", upstreamErr.BodySnippet), zap.Int64("retry_after_ms", upstreamErr.RetryAfter.Milliseconds()), zap.Float64("duration_ms", float64(duration.Microseconds())/1000), } if upstreamErr.Cause != nil { fields = append(fields, zap.Error(upstreamErr.Cause)) } if final { r.ocrLogger(ctx).Error("PaddleOCR 请求失败", fields...) } else { r.ocrLogger(ctx).Warn("PaddleOCR 请求失败,准备重试", fields...) } } func (r *Repository) ocrLogger(ctx context.Context) *zap.Logger { logger := r.logger if logger == nil { logger = zap.NewNop() } fields := []zap.Field{zap.String("module", "chat_ocr")} if requestID := logging.RequestIDFromContext(ctx); requestID != "" { fields = append(fields, zap.String("request_id", requestID)) } if adminID := logging.AdminIDFromContext(ctx); adminID != 0 { fields = append(fields, zap.Uint64("admin_id", adminID)) } return logger.With(fields...) } func isRetriableOCRStatus(status int) bool { switch status { case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: return true default: return false } } func isRetriableOCRNetworkError(ctx context.Context, err error) bool { return err != nil && ctx.Err() == nil } func ocrRetryDelay(attempt int, retryAfter time.Duration) time.Duration { if retryAfter > 0 { return min(retryAfter, 10*time.Second) } delay := ocrRetryBaseDelay * time.Duration(1<<(attempt-1)) // Deterministic jitter avoids synchronized retries without relying on global randomness. jitter := time.Duration((attempt*137)%250) * time.Millisecond return delay + jitter } func parseOCRRetryAfter(value string, now time.Time) time.Duration { value = strings.TrimSpace(value) if value == "" { return 0 } if seconds, err := strconv.Atoi(value); err == nil { return max(0, time.Duration(seconds)*time.Second) } if when, err := http.ParseTime(value); err == nil { return max(0, when.Sub(now)) } return 0 } func sleepWithContext(ctx context.Context, duration time.Duration) error { if duration <= 0 { return nil } timer := time.NewTimer(duration) defer timer.Stop() select { case <-timer.C: return nil case <-ctx.Done(): return ctx.Err() } } func firstJSONText(payload map[string]any, keys ...string) string { for _, key := range keys { value, ok := payload[key] if !ok || value == nil { continue } switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { return truncateOCRLogValue(text, 512) } case float64: return strconv.FormatFloat(typed, 'f', -1, 64) } } return "" } func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return truncateOCRLogValue(value, 256) } } return "" } func truncateOCRLogValue(value string, limit int) string { value = strings.Join(strings.Fields(value), " ") if len(value) <= limit { return value } return value[:limit] + "..." } 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) }