package agentrecharge import ( "context" "encoding/base64" "io" "net/http" "strings" "github.com/break/junhong_cmp_fiber/pkg/constants" apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/storage" ) // PaymentVoucherObjectStore 提供付款凭证附件的元数据与内容读取能力。 type PaymentVoucherObjectStore interface { Stat(ctx context.Context, key string) (*storage.ObjectMetadata, error) Download(ctx context.Context, key string) (io.ReadCloser, error) } // PaymentVoucherRecognizer 是付款凭证识别的外部能力接缝,只暴露支付单号。 type PaymentVoucherRecognizer interface { ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error) } // PaymentVoucherRecognitionResult 是识别结果中本系统消费的唯一字段。 type PaymentVoucherRecognitionResult struct { // ExternalTransactionNo 是识别出的支付单号,仅作交易流水号表单预填值。 ExternalTransactionNo string } // PaymentVoucherOCRService 按附件对象键识别付款凭证,只返回交易流水号预填值。 // 识别不创建申请、不写入任何资金事实字段;其余识别字段一律不返回、不落库。 // // ENG-AUDIT-001 事实决定:识别调用不产生状态变更、不涉及资金与权限,因此 // 不写 Audit Event、Domain Ledger、Integration Log 与 Outbox;调用记录由 Access Log 与 // Gateway 客户端的路径级日志承载,识别载荷与原始结果不进入任何一类事实。 type PaymentVoucherOCRService struct { objects PaymentVoucherObjectStore recognizer PaymentVoucherRecognizer } // NewPaymentVoucherOCRService 创建付款凭证识别用例。 func NewPaymentVoucherOCRService(objects PaymentVoucherObjectStore, recognizer PaymentVoucherRecognizer) *PaymentVoucherOCRService { return &PaymentVoucherOCRService{objects: objects, recognizer: recognizer} } // Recognize 校验附件为图片后调用识别能力,只返回交易流水号预填值。 // 非图片、对象不存在、内容为空或识别失败都返回明确失败,不阻断人工填写。 func (s *PaymentVoucherOCRService) Recognize(ctx context.Context, objectKey string) (*PaymentVoucherRecognitionResult, error) { if s == nil || s.objects == nil || s.recognizer == nil { return nil, apperrors.New(apperrors.CodeServiceUnavailable, "付款凭证识别能力未配置") } key := strings.TrimSpace(objectKey) if key == "" || len([]rune(key)) > constants.AgentRechargeVoucherKeyMaxLength { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象键无效") } metadata, err := s.objects.Stat(ctx, key) if err != nil { return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "付款凭证对象不存在或不可读") } if metadata == nil || metadata.Size <= 0 { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空") } if metadata.Size > constants.AgentRechargeVoucherMaxBytes { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小") } reader, err := s.objects.Download(ctx, key) if err != nil { return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证对象失败") } defer func() { _ = reader.Close() }() content, err := io.ReadAll(io.LimitReader(reader, constants.AgentRechargeVoucherMaxBytes+1)) if err != nil { return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证内容失败") } if int64(len(content)) > constants.AgentRechargeVoucherMaxBytes { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小") } if len(content) == 0 { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空") } if !isPaymentVoucherImage(metadata.ContentType, content) { return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证必须是图片文件") } // base64 编码只存在于本次调用内存中,禁止写入日志、审计或错误信息。 orderNumber, err := s.recognizer.ExtractPaymentVoucherOrderNumber(ctx, base64.StdEncoding.EncodeToString(content)) if err != nil { return nil, err } if strings.TrimSpace(orderNumber) == "" { return nil, apperrors.New(apperrors.CodeGatewayInvalidResp, "未从付款凭证中识别出交易流水号") } return &PaymentVoucherRecognitionResult{ExternalTransactionNo: strings.TrimSpace(orderNumber)}, nil } // isPaymentVoucherImage 校验对象声明的类型为图片,并用内容嗅探拦截被改名的非图片文件。 // 嗅探结果为空或 application/octet-stream 表示未知容器(如 webp),交由识别服务判定; // 明确识别为其他类型的(PDF、压缩包、文本等)直接拒绝。 func isPaymentVoucherImage(declaredContentType string, content []byte) bool { if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(declaredContentType)), "image/") { return false } sniffed := strings.ToLower(strings.TrimSpace(http.DetectContentType(content))) if sniffed == "" || sniffed == "application/octet-stream" || strings.HasPrefix(sniffed, "image/") { return true } return false }