Files
2026-06-28 15:00:54 +08:00

506 lines
14 KiB
Go
Raw Permalink 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"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
const (
maxQrCodeOCRFileSize = 10 * 1024 * 1024
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}
)
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
}
if contentType == "" {
contentType = http.DetectContentType(data)
}
if !strings.HasPrefix(contentType, "image/") {
return nil, "", ErrQrCodeOCRInvalidFile
}
return data, contentType, nil
}
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 := submitPaddleOCRJob(ctx, config, filename, contentType, data)
if err != nil {
return nil, err
}
jsonURL, err := waitPaddleOCRJob(ctx, config, paddleJobID)
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
}
// 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 := 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
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, err)
}
req.Header.Set("Authorization", "bearer "+config.Token)
resp, err := ocrHTTPClient.Do(req)
if err != nil {
// 网络错误不更新 Redis,保留当前状态让前端重试
return record, nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return record, nil
}
var statusPayload paddleOCRJobStatusResponse
if err := json.Unmarshal(respBody, &statusPayload); err != nil {
return record, nil
}
switch statusPayload.Data.State {
case "pending", "running":
record.Status = ocrJobStatusRunning
_ = r.saveOCRJobRecord(ctx, jobID, record)
return record, nil
case "done":
if statusPayload.Data.ResultURL.JSONURL == "" {
record.Status = ocrJobStatusFailed
record.Error = "未返回识别结果地址"
_ = r.saveOCRJobRecord(ctx, jobID, record)
return record, nil
}
rawText, err := fetchPaddleOCRText(ctx, statusPayload.Data.ResultURL.JSONURL)
if err != nil {
record.Status = ocrJobStatusFailed
record.Error = err.Error()
_ = r.saveOCRJobRecord(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.saveOCRJobRecord(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.saveOCRJobRecord(ctx, jobID, record)
return record, nil
}
return record, 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 := ocrHTTPClient.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 < 12; 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 := ocrHTTPClient.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 "", fmt.Errorf("%w: %v", ErrQrCodeOCRUnavailable, ctx.Err())
case <-time.After(2 * time.Second):
}
}
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 := ocrHTTPClient.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)
}