Files
hfb_sys/backend/internal/modules/chat/qrcode_ocr.go
T

879 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}