All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭 - 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕 - tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213) - 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重 - 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护 - 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
110 lines
5.1 KiB
Go
110 lines
5.1 KiB
Go
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
|
||
}
|