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") }