Files
iotlcm/app/server/internal/logic/app/app_card_recognition.go
T

522 lines
16 KiB
Go

package app
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
internalai "git.5iot.com/iotlcm-public/iotlcm-app/internal/ai"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
const (
cardRecognitionCodeRecognized = "CARD_RECOGNIZED"
cardRecognitionCodeAmbiguous = "CARD_AMBIGUOUS"
cardRecognitionCodeUnreadable = "CARD_UNREADABLE"
cardRecognitionCodeTimeout = "RECOGNITION_TIMEOUT"
cardRecognitionCodeFailed = "RECOGNITION_FAILED"
cardRecognitionCodeDisabled = "RECOGNITION_DISABLED"
cardRecognitionCodeRateLimited = "RECOGNITION_RATE_LIMITED"
cardRecognitionDefaultBaseURL = "https://xueyangai.com/v1"
cardRecognitionDefaultModel = "gpt-4o"
cardRecognitionDefaultMaxImageSize = int64(8 * 1024 * 1024)
cardRecognitionDefaultMaxTokens = 256
cardRecognitionDefaultRateCount = 20
cardRecognitionDefaultRatePeriod = time.Minute
cardRecognitionRateLimitRedisKey = "iotlcm:app:card-recognition:rate:"
)
var cardRecognitionCandidatePattern = regexp.MustCompile(`(^|[^0-9])(89([-[:space:]]*[0-9]){17,18})($|[^0-9])`)
type cardRecognitionConfig struct {
Enabled bool
Provider string
APIKey string
BaseURL string
Endpoint string
Model string
Timeout time.Duration
MaxImageBytes int64
MaxTokens int
RateLimitEnabled bool
RateLimitCount int
RateLimitPeriod time.Duration
}
type cardRecognitionModelOutput struct {
Code string `json:"code"`
CardType string `json:"cardType"`
Message string `json:"message"`
ICCID string `json:"iccid"`
CardValue string `json:"cardValue"`
Candidates []string `json:"candidates"`
Confidence float64 `json:"confidence"`
}
type cardRecognitionParsedResult struct {
Code string
Candidates []string
Confidence float64
}
func (s *sAppCard) RecognizeCard(ctx context.Context) (*model.AppCardRecognitionModel, error) {
result := &model.AppCardRecognitionModel{
Code: cardRecognitionCodeFailed,
CardType: "iccid",
Candidates: []string{},
}
req := g.RequestFromCtx(ctx)
if req == nil {
result.Message = "无法读取上传请求,请稍后重试"
return result, nil
}
file := req.GetUploadFile("file")
if file == nil {
result.Code = cardRecognitionCodeUnreadable
result.Message = "请上传一张包含 ICCID 的图片"
return result, nil
}
config := loadCardRecognitionConfig(ctx)
if !config.Enabled || config.APIKey == "" {
result.Code = cardRecognitionCodeDisabled
result.Message = "图片识别服务暂未配置"
return result, nil
}
allowed, err := checkCardRecognitionRateLimit(ctx, config, req)
if err != nil {
g.Log().Warningf(ctx, "card recognition rate limit check failed: %v", err)
} else if !allowed {
result.Code = cardRecognitionCodeRateLimited
result.Message = "图片识别请求过于频繁,请稍后再试"
return result, nil
}
content, contentType, err := readCardRecognitionImage(file, config.MaxImageBytes)
if err != nil {
result.Code = cardRecognitionCodeUnreadable
result.Message = err.Error()
return result, nil
}
rawContent, err := requestCardRecognitionModel(ctx, config, content, contentType)
if err != nil {
g.Log().Warningf(ctx, "card recognition model request failed: %v", err)
if isCardRecognitionTimeoutError(ctx, err) {
result.Code = cardRecognitionCodeTimeout
result.Message = "图片识别超时,请稍后重试"
} else {
result.Code = cardRecognitionCodeFailed
result.Message = "图片识别服务暂时不可用,请稍后重试"
}
return result, nil
}
parsed := parseCardRecognitionResult(rawContent)
result.Confidence = parsed.Confidence
result.Candidates = parsed.Candidates
switch {
case parsed.Code == cardRecognitionCodeUnreadable && len(parsed.Candidates) == 0:
result.Code = cardRecognitionCodeUnreadable
result.Message = "未能从图片中识别出清晰的 ICCID,请重新拍摄"
return result, nil
case parsed.Code == cardRecognitionCodeAmbiguous && len(parsed.Candidates) > 1:
result.Code = cardRecognitionCodeAmbiguous
result.Message = "图片中存在多个可能的 ICCID,请重新拍摄单张卡片"
return result, nil
}
switch len(parsed.Candidates) {
case 0:
result.Code = cardRecognitionCodeUnreadable
result.Message = "未能从图片中识别出清晰的 ICCID,请重新拍摄"
return result, nil
case 1:
result.CardValue = parsed.Candidates[0]
result.Code = cardRecognitionCodeRecognized
result.Message = "已识别出 ICCID"
return result, nil
default:
result.Code = cardRecognitionCodeAmbiguous
result.Message = "图片中存在多个可能的 ICCID,请重新拍摄单张卡片"
return result, nil
}
}
func loadCardRecognitionConfig(ctx context.Context) cardRecognitionConfig {
timeout := 25 * time.Second
if value := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.timeout", "25s").String()); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
timeout = parsed
}
}
if value := appEnvFirst("APP_CARD_RECOGNITION_TIMEOUT"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
timeout = parsed
}
}
maxImageBytes := g.Cfg().MustGet(ctx, "appCardRecognition.maxImageBytes", cardRecognitionDefaultMaxImageSize).Int64()
if value := appEnvFirst("APP_CARD_RECOGNITION_MAX_IMAGE_BYTES"); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil && parsed > 0 {
maxImageBytes = parsed
}
}
if maxImageBytes <= 0 {
maxImageBytes = cardRecognitionDefaultMaxImageSize
}
maxTokens := g.Cfg().MustGet(ctx, "appCardRecognition.maxTokens", cardRecognitionDefaultMaxTokens).Int()
if value := appEnvFirst("APP_CARD_RECOGNITION_MAX_TOKENS"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
maxTokens = parsed
}
}
if maxTokens <= 0 {
maxTokens = cardRecognitionDefaultMaxTokens
}
rateLimitPeriod := cardRecognitionDefaultRatePeriod
if value := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.period", rateLimitPeriod.String()).String()); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
rateLimitPeriod = parsed
}
}
if value := appEnvFirst("APP_CARD_RECOGNITION_RATE_LIMIT_PERIOD"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
rateLimitPeriod = parsed
}
}
rateLimitCount := g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.count", cardRecognitionDefaultRateCount).Int()
if value := appEnvFirst("APP_CARD_RECOGNITION_RATE_LIMIT_COUNT"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
rateLimitCount = parsed
}
}
if rateLimitCount <= 0 {
rateLimitCount = cardRecognitionDefaultRateCount
}
baseURL := appEnvFirst("APP_CARD_RECOGNITION_BASE_URL")
endpoint := appEnvFirst("APP_CARD_RECOGNITION_ENDPOINT")
if baseURL == "" && endpoint == "" {
baseURL = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.baseUrl", "").String())
endpoint = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.endpoint", "").String())
}
if baseURL == "" && endpoint == "" {
baseURL = cardRecognitionDefaultBaseURL
}
modelName := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.model", cardRecognitionDefaultModel).String())
if value := appEnvFirst("APP_CARD_RECOGNITION_MODEL"); value != "" {
modelName = value
}
if modelName == "" {
modelName = cardRecognitionDefaultModel
}
apiKey := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.apiKey", "").String())
if value := appEnvFirst("APP_CARD_RECOGNITION_API_KEY", "XUEYANGAI_API_KEY"); value != "" {
apiKey = value
}
provider := appEnvFirst("APP_CARD_RECOGNITION_PROVIDER")
if provider == "" {
provider = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.provider", internalai.ProviderOpenAICompatible).String())
}
return cardRecognitionConfig{
Enabled: appEnvBool("APP_CARD_RECOGNITION_ENABLED", g.Cfg().MustGet(ctx, "appCardRecognition.enabled", false).Bool()),
Provider: provider,
APIKey: apiKey,
BaseURL: baseURL,
Endpoint: endpoint,
Model: modelName,
Timeout: timeout,
MaxImageBytes: maxImageBytes,
MaxTokens: maxTokens,
RateLimitEnabled: appEnvBool("APP_CARD_RECOGNITION_RATE_LIMIT_ENABLED", g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.enabled", true).Bool()),
RateLimitCount: rateLimitCount,
RateLimitPeriod: rateLimitPeriod,
}
}
func readCardRecognitionImage(file *ghttp.UploadFile, maxImageBytes int64) ([]byte, string, error) {
if file == nil || file.FileHeader == nil {
return nil, "", gerror.New("上传图片无效")
}
if maxImageBytes <= 0 {
maxImageBytes = cardRecognitionDefaultMaxImageSize
}
if file.Size > maxImageBytes {
return nil, "", gerror.Newf("图片不能超过 %d MB", maxImageBytes/(1024*1024))
}
reader, err := file.Open()
if err != nil {
return nil, "", gerror.New("无法读取图片")
}
defer reader.Close()
content, err := io.ReadAll(io.LimitReader(reader, maxImageBytes+1))
if err != nil {
return nil, "", gerror.New("无法读取图片")
}
if int64(len(content)) > maxImageBytes {
return nil, "", gerror.Newf("图片不能超过 %d MB", maxImageBytes/(1024*1024))
}
contentType := strings.ToLower(strings.TrimSpace(file.Header.Get("Content-Type")))
if contentType == "" || contentType == "application/octet-stream" {
contentType = http.DetectContentType(content)
}
switch contentType {
case "image/jpeg", "image/png", "image/webp":
default:
return nil, "", gerror.New("仅支持 JPG、PNG 或 WEBP 图片")
}
return content, contentType, nil
}
func checkCardRecognitionRateLimit(ctx context.Context, config cardRecognitionConfig, req *ghttp.Request) (bool, error) {
if !config.RateLimitEnabled || config.RateLimitCount <= 0 {
return true, nil
}
periodSeconds := int(config.RateLimitPeriod.Seconds())
if periodSeconds <= 0 {
periodSeconds = int(cardRecognitionDefaultRatePeriod.Seconds())
}
clientIP := "unknown"
if req != nil {
if value := strings.TrimSpace(req.GetClientIp()); value != "" {
clientIP = value
}
}
key := cardRecognitionRateLimitRedisKey + cleanCardRecognitionRateLimitKey(clientIP)
script := `
local v = redis.call("INCR", KEYS[1])
if v == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return v`
res, err := g.Redis().Do(ctx, "EVAL", script, 1, key, periodSeconds)
if err != nil {
return true, gerror.Wrap(err, "检查图片识别限流失败")
}
return res.Int() <= config.RateLimitCount, nil
}
func cleanCardRecognitionRateLimitKey(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, value)
}
func requestCardRecognitionModel(ctx context.Context, config cardRecognitionConfig, content []byte, contentType string) (string, error) {
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
chatModel, err := internalai.NewChatModel(requestCtx, internalai.ChatModelConfig{
Provider: config.Provider,
APIKey: config.APIKey,
BaseURL: config.BaseURL,
Endpoint: config.Endpoint,
Model: config.Model,
Timeout: config.Timeout,
MaxTokens: config.MaxTokens,
JSONOutput: true,
})
if err != nil {
return "", err
}
imageBase64 := base64.StdEncoding.EncodeToString(content)
response, err := chatModel.Generate(requestCtx, []*schema.Message{
{
Role: schema.User,
UserInputMultiContent: []schema.MessageInputPart{
{
Type: schema.ChatMessagePartTypeText,
Text: "请只识别图片中物联网卡的 ICCID。忽略 MSISDN、IMSI、IMEI、订单号和其他数字。请仅返回 JSON,不要返回 Markdown 或解释:{\"code\":\"CARD_RECOGNIZED|CARD_AMBIGUOUS|CARD_UNREADABLE\",\"cardType\":\"iccid\",\"iccid\":\"19或20位数字,没有则为空\",\"candidates\":[\"候选ICCID\"],\"confidence\":0到1之间的小数}。图片模糊、遮挡、反光或无法确定时,iccid 置空并返回 CARD_UNREADABLE;有多个卡号时返回 CARD_AMBIGUOUS,并把全部候选 ICCID 放入 candidates。",
},
{
Type: schema.ChatMessagePartTypeImageURL,
Image: &schema.MessageInputImage{
MessagePartCommon: schema.MessagePartCommon{
Base64Data: &imageBase64,
MIMEType: contentType,
},
Detail: schema.ImageURLDetailHigh,
},
},
},
},
})
if err != nil {
return "", gerror.Wrap(err, "Eino调用图片识别模型失败")
}
if response == nil || strings.TrimSpace(response.Content) == "" {
return "", gerror.New("图片识别响应为空")
}
return response.Content, nil
}
func parseCardRecognitionResult(raw string) cardRecognitionParsedResult {
raw = trimCardRecognitionJSON(raw)
output := cardRecognitionModelOutput{}
if json.Unmarshal([]byte(raw), &output) == nil {
rawValues := append([]string{output.ICCID, output.CardValue}, output.Candidates...)
candidates := normalizeCardRecognitionCandidates(rawValues)
code := normalizeCardRecognitionCode(output.Code)
if code == "" {
switch len(candidates) {
case 0:
code = cardRecognitionCodeUnreadable
case 1:
code = cardRecognitionCodeRecognized
default:
code = cardRecognitionCodeAmbiguous
}
}
return cardRecognitionParsedResult{
Code: code,
Candidates: candidates,
Confidence: clampCardRecognitionConfidence(output.Confidence),
}
}
candidates := normalizeCardRecognitionCandidates([]string{raw})
code := cardRecognitionCodeUnreadable
if len(candidates) == 1 {
code = cardRecognitionCodeRecognized
} else if len(candidates) > 1 {
code = cardRecognitionCodeAmbiguous
}
return cardRecognitionParsedResult{
Code: code,
Candidates: candidates,
Confidence: 0,
}
}
func normalizeCardRecognitionCandidates(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
for _, candidate := range extractCardRecognitionCandidates(value) {
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
result = append(result, candidate)
}
}
return result
}
func extractCardRecognitionCandidates(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
result := make([]string, 0, 1)
if cleaned := cleanCardRecognitionCandidate(value); isValidCardRecognitionCandidate(cleaned) {
result = append(result, cleaned)
}
for _, match := range cardRecognitionCandidatePattern.FindAllStringSubmatch(value, -1) {
if len(match) < 3 {
continue
}
if cleaned := cleanCardRecognitionCandidate(match[2]); isValidCardRecognitionCandidate(cleaned) {
result = append(result, cleaned)
}
}
return result
}
func cleanCardRecognitionCandidate(value string) string {
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, strings.TrimSpace(value))
}
func isValidCardRecognitionCandidate(value string) bool {
return (len(value) == 19 || len(value) == 20) && strings.HasPrefix(value, "89")
}
func clampCardRecognitionConfidence(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func trimCardRecognitionJSON(raw string) string {
raw = strings.TrimSpace(raw)
for _, prefix := range []string{"```json", "```JSON", "```"} {
if strings.HasPrefix(raw, prefix) {
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
break
}
}
if strings.HasSuffix(raw, "```") {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "```"))
}
return raw
}
func normalizeCardRecognitionCode(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case cardRecognitionCodeRecognized:
return cardRecognitionCodeRecognized
case cardRecognitionCodeAmbiguous:
return cardRecognitionCodeAmbiguous
case cardRecognitionCodeUnreadable:
return cardRecognitionCodeUnreadable
default:
return ""
}
}
func isCardRecognitionTimeoutError(ctx context.Context, err error) bool {
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
return true
}
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "timeout")
}