diff --git a/docs/integrations/gateway/README.md b/docs/integrations/gateway/README.md index b63aab7..2b1d1c4 100644 --- a/docs/integrations/gateway/README.md +++ b/docs/integrations/gateway/README.md @@ -4,13 +4,27 @@ - Owner:IoT Gateway 适配维护人 - 实现:`internal/gateway/` -- 核验日期:2026-08-07 -- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go` +- 核验日期:2026-09-11 +- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go`、`payment_voucher.go` ## 当前实际使用范围 Gateway 是运营商流量卡、实名、停复机、限速和设备信息的统一封装入口。具体路径、请求字段和响应字段以同目录详细协议与 `internal/gateway/*.go` 的实际调用交集为准;文档中出现但代码未调用的接口不视为系统能力。 +付款凭证识别(`POST /ai/ocr/extract-payment`,入参 `image_base64`)由 `internal/gateway/payment_voucher.go` 封装,当前唯一调用方是代理线下预存款申请的「交易流水号表单预填」。该能力只消费响应中的 `order_number`(作为交易流水号预填值);`amount`、`remark`、`payment_method`、`payee`、`payment_time` 不进入本系统响应、不预填、不落库,识别结果不是资金事实。核验证据:`internal/gateway/payment_voucher.go` 的类型定义只对外暴露支付单号,`internal/application/agentrecharge/payment_voucher_ocr.go` 只返回该字段,接口响应 DTO 仅含 `external_transaction_no`。 + +付款凭证识别刻意不走 `doRequest` / `doRequestWithResponse`:前者在 Info 级别打印加密前完整请求体、后者在 Info 级别打印完整原始响应,会把凭证图片内容与识别原始结果写进日志。该能力改用 `Client.doRequestWithoutPayloadLog`,仅记录路径、耗时与结果字节数摘要;既有能力的请求与日志语义保持不变(`internal/gateway/client.go` 中 `executeWithRetry` 的 `logPayload` 分支)。 + +### 付款凭证识别的已知限制 + +- **长号码可能不完整**:对位数较多的转账单号,该接口可能只返回前若干位,实测存在识别值与凭证图片所示号码不一致的情况(位数少于凭证所示)。连续多次识别同一凭证所得长度与内容稳定,属上游侧确定性截断,而非本系统侧裁剪;预填值**必须**由提交人对照凭证人工核对,系统以人工确认值为准。 +- **单号缺失即失败**:响应未给出单号时,本系统按识别失败返回明确失败(`CodeGatewayInvalidResp`,中文提示),不返回空值。 +- **字段类型会漂移**:响应 `data` 中 `amount` 为 JSON 数值而非字符串。本系统只解码 `order_number`,不声明其余字段,故不受类型漂移影响;新增消费字段前必须重新核对上游类型。 +- **解析失败不回显原文**:响应解码失败时只返回固定中文提示,不携带底层解析错误,避免第三方库的错误消息把识别原始结果带进日志与错误上下文。 +- **单次识别只接受单个附件键**,图片由后端读取对象存储后编码,Gateway 凭证不下发前端;识别结果不落库、不构成资金事实。 + +本条限制的核验方式(可复现、不依赖样本取值):对同一图片凭证**连续三次**调用该识别接口,比较三次返回值的**位数与内容是否一致**——一致说明是上游确定性行为而非随机抖动;再将该位数与凭证图片所示号码的位数(用等长掩码计数,只比位数)对照,得出是否缺位。判定责任方时看本系统的解码路径 `internal/gateway/payment_voucher.go`:它只对返回值做 `strings.TrimSpace`,无截断、无按长度裁剪、无正则截取,因此位数差异只能来自上游。识别结果不落库,复核该接口的返回值需重新发起识别调用,不能从业务表反查。 + ## 配置、认证与报文 配置键为 `gateway.base_url`、`gateway.app_id`、`gateway.app_secret`、`gateway.timeout`。业务参数先包装为 `{"params": ...}`,使用 AppSecret 做 AES-128-ECB 加密;外层请求含 `appId`、`data`、`sign`、`timestamp`,签名使用 MD5。HTTP 方法统一为 POST,内容类型为 `application/json;charset=utf-8`。HTTP 200 且 Gateway `code=200` 才算成功,`data` 再按具体能力解码。 diff --git a/docs/product/2026-08-迭代-PRD-讨论稿.md b/docs/product/2026-08-迭代-PRD-讨论稿.md index 8ba25c3..43b682a 100644 --- a/docs/product/2026-08-迭代-PRD-讨论稿.md +++ b/docs/product/2026-08-迭代-PRD-讨论稿.md @@ -33,7 +33,7 @@ | AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 | | AUG26-015 | 报表管理 | PRD-08-016(报表,原编号重复) | | AUG26-016 | 优先轮询通道 | PRD-08-019 | -| AUG26-017 | 代理自充收款方式 | PRD-08-021 | +| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013(预存款审批字段) | ## 1. 已确认的领域语言 diff --git a/internal/application/agentrecharge/offline_creation.go b/internal/application/agentrecharge/offline_creation.go index f39395a..41f3027 100644 --- a/internal/application/agentrecharge/offline_creation.go +++ b/internal/application/agentrecharge/offline_creation.go @@ -24,7 +24,12 @@ type CreateOfflineCommand struct { RechargeNo string Amount int64 PaymentVoucherKeys []string - Remark string + OtherVoucherKeys []string + // OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。 + OfflinePaymentMethodID uint + // ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。 + ExternalTransactionNo string + Remark string } // CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。 @@ -78,8 +83,20 @@ func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID, RechargeNo: record.RechargeNo, Amount: record.Amount, PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark, + OtherVoucherKeys: []string(record.OtherVoucherKeys), } - submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName) + if record.ExternalTransactionNo != nil { + command.ExternalTransactionNo = *record.ExternalTransactionNo + } + // 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。 + var frozenCode, frozenName string + if record.OfflinePaymentMethodCode != nil { + frozenCode = *record.OfflinePaymentMethodCode + } + if record.OfflinePaymentMethodName != nil { + frozenName = *record.OfflinePaymentMethodName + } + submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName) if err != nil { return nil, err } @@ -154,20 +171,31 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl if err != nil { return nil, err } - submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName) - if err != nil { - return nil, err - } paymentChannel := constants.RechargeMethodOffline - record := &model.AgentRechargeRecord{ - UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID, - RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount, - PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel, - PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark), - Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, - } + externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo) + var record *model.AgentRechargeRecord var approvalStatus int err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID) + if err != nil { + return err + } + submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name) + if err != nil { + return err + } + record = &model.AgentRechargeRecord{ + UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID, + RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount, + PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel, + PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark), + ExternalTransactionNo: &externalTransactionNo, + OfflinePaymentMethodID: &paymentMethod.ID, + OfflinePaymentMethodCode: &paymentMethod.Code, + OfflinePaymentMethodName: &paymentMethod.Name, + OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys), + Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, + } if err := tx.WithContext(ctx).Create(record).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败") } @@ -218,17 +246,71 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error { if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount { return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围") } - if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 { - return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证") + if command.OfflinePaymentMethodID == 0 { + return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式") } - for _, key := range command.PaymentVoucherKeys { - if strings.TrimSpace(key) == "" { - return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空") - } + if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil { + return err + } + if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil { + return err + } + return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个") +} + +// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。 +func validateRechargeTransactionNo(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号") + } + if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength { + return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制") } return nil } +// validateVoucherKeys 校验凭证对象键数量与内容,minCount 为 0 时允许为空。 +func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error { + if len(keys) < minCount || len(keys) > maxCount { + return errors.New(errors.CodeInvalidParam, message) + } + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空") + } + if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength { + return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制") + } + if _, exists := seen[trimmed]; exists { + return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复") + } + seen[trimmed] = struct{}{} + } + return nil +} + +// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。 +// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。 +func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) { + if id == 0 { + return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式") + } + var paymentMethod model.EmployeeCollectionPaymentMethod + if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败") + } + if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled { + return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled) + } + return &paymentMethod, nil +} + func (s *OfflineCreationService) loadHistoricalFacts( ctx context.Context, record *model.AgentRechargeRecord, ) (*model.Account, *model.Shop, *model.AgentWallet, error) { @@ -291,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts( return &account, &shop, &wallet, nil } -func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) { +func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) { submitterSnapshot, err := sonic.Marshal(map[string]any{ "account_id": command.SubmitterAccountID, "account_name": submitterName, "user_type": command.SubmitterUserType, @@ -300,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败") } requestSnapshot, err := sonic.Marshal(map[string]any{ - constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo), - constants.ApprovalFieldShopID: command.ShopID, - constants.ApprovalFieldShopName: shopName, - constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100), - constants.ApprovalFieldAmountCent: command.Amount, - constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys, - constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark), - constants.ApprovalFieldSubmitterID: command.SubmitterAccountID, - constants.ApprovalFieldSubmitterName: submitterName, + constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo), + constants.ApprovalFieldShopID: command.ShopID, + constants.ApprovalFieldShopName: shopName, + constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100), + constants.ApprovalFieldAmountCent: command.Amount, + constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys, + constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark), + constants.ApprovalFieldSubmitterID: command.SubmitterAccountID, + constants.ApprovalFieldSubmitterName: submitterName, + constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName, + constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode, + constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo), + constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys, }) if err != nil { return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败") diff --git a/internal/application/agentrecharge/online_creation.go b/internal/application/agentrecharge/online_creation.go index 8ad912c..a0f137a 100644 --- a/internal/application/agentrecharge/online_creation.go +++ b/internal/application/agentrecharge/online_creation.go @@ -53,6 +53,12 @@ type OnlineCreationService struct { alipay OnlinePaymentPort fuiou OnlinePaymentPort audit PaymentAuditWriter + policy *OnlinePaymentMethodPolicy +} + +// SetPaymentMethodPolicy 注入代理在线自充允许范围策略。 +func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) { + s.policy = policy } // NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。 @@ -62,7 +68,7 @@ func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoade // Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。 func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) { - if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil { + if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil { return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置") } command.PaymentMethod = strings.TrimSpace(command.PaymentMethod) @@ -81,9 +87,14 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin if err != nil { return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败") } + // 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单, + // 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。 if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found { return replay, err } + if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil { + return nil, err + } account, shop, wallet, err := s.loadCreationFacts(ctx, command) if err != nil { return nil, err @@ -127,15 +138,25 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin return result, nil } -// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。 +// AvailablePaymentMethods 按允许范围与可用商户池交集返回在线支付方式。 func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) { result := AvailablePaymentMethodsResult{ Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount, } - if userType != constants.UserTypeAgent { - return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式") + if s == nil || s.db == nil || s.runtime == nil { + return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置") } - for _, method := range []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay} { + if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform { + return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式") + } + if s.policy == nil { + return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置") + } + allowed, err := s.policy.AllowedMethods(ctx) + if err != nil { + return result, err + } + for _, method := range allowed { var merchants []model.PaymentMerchant err := s.db.WithContext(ctx). Model(&model.PaymentMerchant{}). diff --git a/internal/application/agentrecharge/online_payment_policy.go b/internal/application/agentrecharge/online_payment_policy.go new file mode 100644 index 0000000..ecbe635 --- /dev/null +++ b/internal/application/agentrecharge/online_payment_policy.go @@ -0,0 +1,58 @@ +package agentrecharge + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// OnlinePaymentMethodConfigReader 提供代理在线自充允许范围的严格读取能力。 +type OnlinePaymentMethodConfigReader interface { + GetStrict(ctx context.Context, key string) (string, error) +} + +// OnlinePaymentMethodPolicy 将受控配置值映射为对外可见的线上支付方式集合。 +type OnlinePaymentMethodPolicy struct { + reader OnlinePaymentMethodConfigReader +} + +// NewOnlinePaymentMethodPolicy 创建代理在线自充允许范围策略。 +func NewOnlinePaymentMethodPolicy(reader OnlinePaymentMethodConfigReader) *OnlinePaymentMethodPolicy { + return &OnlinePaymentMethodPolicy{reader: reader} +} + +// AllowedMethods 严格读取允许范围;配置缺失使用注册默认值,非法值失败关闭。 +func (p *OnlinePaymentMethodPolicy) AllowedMethods(ctx context.Context) ([]string, error) { + if p == nil || p.reader == nil { + return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围未配置") + } + value, err := p.reader.GetStrict(ctx, constants.SystemConfigAgentSelfRechargeAllowedMethods) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "读取代理在线充值允许范围失败") + } + switch value { + case constants.AgentSelfRechargeAllowedWechatOnly: + return []string{constants.RechargeMethodWechat}, nil + case constants.AgentSelfRechargeAllowedAlipayOnly: + return []string{constants.RechargeMethodAlipay}, nil + case constants.AgentSelfRechargeAllowedBoth: + return []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}, nil + default: + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "代理在线充值允许范围值非法") + } +} + +// IsAllowed 判断业务支付方式是否在当前受控允许范围内。 +func (p *OnlinePaymentMethodPolicy) IsAllowed(ctx context.Context, method string) error { + methods, err := p.AllowedMethods(ctx) + if err != nil { + return err + } + for _, allowed := range methods { + if allowed == method { + return nil + } + } + return apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付方式不在代理在线充值允许范围内") +} diff --git a/internal/application/agentrecharge/payment_voucher_ocr.go b/internal/application/agentrecharge/payment_voucher_ocr.go new file mode 100644 index 0000000..665d5f0 --- /dev/null +++ b/internal/application/agentrecharge/payment_voucher_ocr.go @@ -0,0 +1,109 @@ +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 +} diff --git a/internal/application/employeecollection/payment_method.go b/internal/application/employeecollection/payment_method.go index 0229f4c..0e646b0 100644 --- a/internal/application/employeecollection/payment_method.go +++ b/internal/application/employeecollection/payment_method.go @@ -130,7 +130,7 @@ func (s *PaymentMethodService) Update( } if referenced > 0 { return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced, - "线下收款方式已被核销申请引用,不能修改稳定编码") + "线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码") } if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil { return err @@ -276,14 +276,23 @@ func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code str return nil } -// countPaymentMethodReferences 统计引用该收款方式的核销申请数量。 +// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。 +// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。 func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) { - var count int64 + var applicationCount int64 if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}). - Where("payment_method_id = ?", id).Count(&count).Error; err != nil { + Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil { return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败") } - return count, nil + if applicationCount > 0 { + return applicationCount, nil + } + var rechargeCount int64 + if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败") + } + return rechargeCount, nil } // mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。 diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index 33d8440..0fd7128 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -317,6 +317,10 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, {Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"}, {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, + {Code: constants.ApprovalFieldOfflinePaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式名称快照"}, + {Code: constants.ApprovalFieldOfflinePaymentMethodCode, Name: "线下收款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式稳定编码快照"}, + {Code: constants.ApprovalFieldExternalTransactionNo, Name: "交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验;与在线渠道交易号无关"}, + {Code: constants.ApprovalFieldOtherVoucherKey, Name: "其他凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的其他凭证列表"}, }, true case constants.ApprovalBusinessTypeRefund: return []dto.WeComBusinessFieldResponse{ diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index aded6ab..0908e64 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -1,6 +1,7 @@ package bootstrap import ( + agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" @@ -110,6 +111,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis) } systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts) + if svc.AgentRechargeOnline != nil { + svc.AgentRechargeOnline.SetPaymentMethodPolicy(agentrechargeApp.NewOnlinePaymentMethodPolicy(systemConfigReader)) + } paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader) clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy) clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB)) @@ -304,6 +308,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate) handler.SetOnlineCreationService(svc.AgentRechargeOnline) handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB)) + handler.SetPaymentVoucherOCRService(svc.AgentRechargeVoucherOCR) + handler.SetSystemConfigUpdateService(systemConfigUpdate) return handler }(), Refund: admin.NewRefundHandler(svc.Refund), diff --git a/internal/bootstrap/payment_method_config.go b/internal/bootstrap/payment_method_config.go index bbbc861..e8cad74 100644 --- a/internal/bootstrap/payment_method_config.go +++ b/internal/bootstrap/payment_method_config.go @@ -15,6 +15,7 @@ func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, log definitions := []systemconfig.Definition{ {Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue}, {Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue}, + {Key: constants.SystemConfigAgentSelfRechargeAllowedMethods, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.AgentSelfRechargeAllowedBoth, Description: "代理在线自充允许的支付方式范围", Control: "payment_methods", EnumValues: []string{constants.AgentSelfRechargeAllowedWechatOnly, constants.AgentSelfRechargeAllowedAlipayOnly, constants.AgentSelfRechargeAllowedBoth}}, } for _, definition := range definitions { if existing, exists := registry.Get(definition.Key); exists { diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 5da1d89..0937b09 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -129,6 +129,7 @@ type services struct { AgentRecharge *agentRechargeSvc.Service AgentRechargeOnline *agentrechargeApp.OnlineCreationService AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService + AgentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService PackageActivation *packageSvc.ActivationService Refund *refundSvc.Service TrafficQuery *trafficSvc.QueryService @@ -303,6 +304,11 @@ func initServices(s *stores, deps *Dependencies) *services { paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()), auditWriter, ) + // 付款凭证识别需要对象存储与 Gateway;任一缺失时不装配,接口统一返回能力未配置。 + var agentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService + if deps.StorageService != nil && deps.GatewayClient != nil { + agentRechargeVoucherOCR = agentrechargeApp.NewPaymentVoucherOCRService(deps.StorageService.Provider(), deps.GatewayClient) + } refundService := refundSvc.New( deps.DB, s.RefundRequest, @@ -465,6 +471,7 @@ func initServices(s *stores, deps *Dependencies) *services { AgentRecharge: agentRechargeService, AgentRechargeOnline: agentRechargeOnline, AgentRechargePaymentConfirm: agentRechargePaymentConfirm, + AgentRechargeVoucherOCR: agentRechargeVoucherOCR, PackageActivation: packageActivation, TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), OperationPassword: operationPassword, diff --git a/internal/gateway/client.go b/internal/gateway/client.go index 7e4a39c..26fa338 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -99,8 +99,6 @@ func (c *Client) WithRetry(maxRetries int) *Client { // 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码 // params: 请求参数结构体,内部自动包装为 {"params": } 格式 func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { - startTime := time.Now() - // 将参数包装为 {"params": ...} 格式后序列化 wrapper := requestWrapper{Params: params} dataBytes, err := sonic.Marshal(wrapper) @@ -115,6 +113,28 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{}) return nil, err } + return c.executeWithRetry(ctx, path, encryptedData, true) +} + +// doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。 +// 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。 +func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) { + dataBytes, err := sonic.Marshal(requestWrapper{Params: params}) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败") + } + encryptedData, err := aesEncrypt(dataBytes, c.appSecret) + if err != nil { + return nil, err + } + return c.executeWithRetry(ctx, path, encryptedData, false) +} + +// executeWithRetry 按现有重试语义发送一次已加密请求。 +// logPayload 为 false 时不记录响应体,只记录路径、耗时与结果字节数摘要。 +func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData string, logPayload bool) (json.RawMessage, error) { + startTime := time.Now() + // 带重试的 HTTP 请求 var lastErr error observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver) @@ -160,11 +180,19 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{}) // 成功 duration := time.Since(startTime) - c.logger.Debug("Gateway 请求成功", - zap.String("path", path), - zap.Duration("duration", duration), - zap.Any("result", result), - ) + if logPayload { + c.logger.Debug("Gateway 请求成功", + zap.String("path", path), + zap.Duration("duration", duration), + zap.Any("result", result), + ) + } else { + c.logger.Debug("Gateway 请求成功", + zap.String("path", path), + zap.Duration("duration", duration), + zap.Int("result_bytes", len(result)), + ) + } return result, nil } diff --git a/internal/gateway/payment_voucher.go b/internal/gateway/payment_voucher.go new file mode 100644 index 0000000..9cdbc61 --- /dev/null +++ b/internal/gateway/payment_voucher.go @@ -0,0 +1,60 @@ +// Package gateway 提供付款凭证识别能力,仅用于交易流水号表单预填。 +// 识别结果不是资金事实,也不代表系统已完成任何资金动作。 +package gateway + +import ( + "context" + "reflect" + "strings" + + "github.com/bytedance/sonic" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// paymentVoucherRecognitionPath 是付款凭证识别接口路径。 +const paymentVoucherRecognitionPath = "/ai/ocr/extract-payment" + +// PaymentVoucherExtractionRequest 是付款凭证识别请求,只传图片内容。 +type PaymentVoucherExtractionRequest struct { + // ImageBase64 是付款凭证图片的 base64 编码内容。 + ImageBase64 string `json:"image_base64"` +} + +// paymentVoucherExtraction 只解码本系统消费的支付单号。 +// 识别响应还含 amount(数值)、payee、payment_method、payment_time 与 remark, +// 这些字段刻意不声明、不解码:既不进入响应、不预填、不落库,也不被本进程持有, +// 同时避免上游字段类型漂移(实测 amount 为 JSON 数值)导致整条响应解析失败。 +type paymentVoucherExtraction struct { + OrderNumber string `json:"order_number"` +} + +// ExtractPaymentVoucherOrderNumber 识别付款凭证图片并只返回识别出的支付单号。 +// 该能力刻意不使用记录完整请求体与响应体的泛型入口,日志只含路径、耗时与结果摘要; +// 返回空字符串表示识别服务未给出支付单号,由调用方决定失败口径。 +func (c *Client) ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error) { + if strings.TrimSpace(imageBase64) == "" { + return "", errors.New(errors.CodeInvalidParam, "付款凭证图片内容不能为空") + } + data, err := c.doRequestWithoutPayloadLog(ctx, paymentVoucherRecognitionPath, PaymentVoucherExtractionRequest{ + ImageBase64: imageBase64, + }) + if err != nil { + return "", err + } + var extraction paymentVoucherExtraction + if err := sonic.Unmarshal(data, &extraction); err != nil { + // 刻意不记录 err.Error():sonic 的类型错误消息会内嵌响应 JSON 原文片段, + // 一旦进入日志或错误上下文就等于记录识别原始结果(金额、单号等)。 + // 只记录可诊断且非敏感的摘要:路径、响应字节数与错误类型名, + // 足以区分语法错、类型错与空响应,又不携带任何载荷内容。 + c.logger.Warn("付款凭证识别响应解析失败", + zap.String("path", paymentVoucherRecognitionPath), + zap.Int("result_bytes", len(data)), + zap.String("err_kind", reflect.TypeOf(err).String()), + ) + return "", errors.New(errors.CodeGatewayInvalidResp, "解析付款凭证识别结果失败") + } + return strings.TrimSpace(extraction.OrderNumber), nil +} diff --git a/internal/handler/admin/agent_recharge.go b/internal/handler/admin/agent_recharge.go index ecc3e2d..5d7f2b9 100644 --- a/internal/handler/admin/agent_recharge.go +++ b/internal/handler/admin/agent_recharge.go @@ -4,12 +4,14 @@ import ( "bytes" "strconv" "strings" + "time" "github.com/bytedance/sonic" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" "github.com/break/junhong_cmp_fiber/internal/model/dto" agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge" @@ -24,6 +26,8 @@ type AgentRechargeHandler struct { service *agentRechargeSvc.Service online *agentrechargeapp.OnlineCreationService status *agentrechargequery.PaymentStatusQuery + ocr *agentrechargeapp.PaymentVoucherOCRService + config *systemconfigapp.UpdateService validator *validator.Validate } @@ -37,6 +41,16 @@ func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.P h.status = query } +// SetPaymentVoucherOCRService 注入付款凭证识别用例。 +func (h *AgentRechargeHandler) SetPaymentVoucherOCRService(service *agentrechargeapp.PaymentVoucherOCRService) { + h.ocr = service +} + +// SetSystemConfigUpdateService 注入受控系统配置写服务,用于代理自充允许范围修改。 +func (h *AgentRechargeHandler) SetSystemConfigUpdateService(service *systemconfigapp.UpdateService) { + h.config = service +} + // NewAgentRechargeHandler 创建代理预充值 Handler func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler { return &AgentRechargeHandler{service: service, validator: validator} @@ -73,8 +87,9 @@ func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRec if h.online == nil { return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置") } - if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" { - return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注") + if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || len(req.OtherVoucherKey) > 0 || + req.OfflinePaymentMethodID != 0 || strings.TrimSpace(req.ExternalTransactionNo) != "" || strings.TrimSpace(req.Remark) != "" { + return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、收款方式、交易流水号、支付凭证或运营备注") } result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{ AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()), @@ -108,6 +123,69 @@ func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error { }) } +// SelfRechargePaymentMethods 查询代理自充实际可用支付方式。 +// GET /api/admin/agent-self-recharge-payment-methods +// 响应只含交集结果,不含允许范围、商户身份或凭证。 +func (h *AgentRechargeHandler) SelfRechargePaymentMethods(c *fiber.Ctx) error { + return h.PaymentMethods(c) +} + +// UpdateSelfRechargePaymentMethods 修改代理在线自充允许范围。 +// PUT /api/admin/agent-self-recharge-payment-methods +// 复用受控系统配置写服务,获得超级管理员限定、咨询锁串行与前后值审计。 +func (h *AgentRechargeHandler) UpdateSelfRechargePaymentMethods(c *fiber.Ctx) error { + var req dto.AgentSelfRechargePaymentMethodsUpdateRequest + decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.config == nil { + return errors.New(errors.CodeServiceUnavailable, "代理自充允许范围维护能力未配置") + } + item, err := h.config.Execute(c.UserContext(), constants.SystemConfigAgentSelfRechargeAllowedMethods, + dto.UpdateSystemConfigRequest{Value: req.AllowedMethods}) + if err != nil { + return err + } + updatedAt := "" + if item.UpdatedAt != nil { + updatedAt = item.UpdatedAt.UTC().Format(time.RFC3339) + } + return response.Success(c, &dto.AgentSelfRechargePaymentMethodsUpdateResponse{ + AllowedMethods: item.Value, AllowedMethodsName: constants.GetAgentSelfRechargeAllowedMethodsName(item.Value), + UpdatedAt: updatedAt, + }) +} + +// PaymentVoucherOCR 识别付款凭证,只返回交易流水号预填值。 +// POST /api/admin/agent-recharges/payment-voucher-ocr +// 识别失败返回明确失败,不影响提交人人工填写交易流水号后创建申请。 +func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error { + var req dto.AgentRechargePaymentVoucherOCRRequest + decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.ocr == nil { + return errors.New(errors.CodeServiceUnavailable, "付款凭证识别能力未配置") + } + result, err := h.ocr.Recognize(c.UserContext(), req.PaymentVoucherKey) + if err != nil { + return err + } + return response.Success(c, &dto.AgentRechargePaymentVoucherOCRResponse{ + ExternalTransactionNo: result.ExternalTransactionNo, + }) +} + // List 查询代理充值订单列表 // GET /api/admin/agent-recharges func (h *AgentRechargeHandler) List(c *fiber.Ctx) error { diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index b0916c7..952685d 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -74,30 +74,35 @@ func (AgentWalletTransaction) TableName() string { // AgentRechargeRecord 代理充值记录模型 // 记录所有代理充值操作 type AgentRechargeRecord struct { - ID uint `gorm:"column:id;primaryKey" json:"id"` - UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` - AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"` - ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` - RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"` - PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"` - PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` - PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` - PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` - Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"` - PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"` - Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` - RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` - ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"` - RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"` - RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"` - PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` - CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` - ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` - EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` + ID uint `gorm:"column:id;primaryKey" json:"id"` + UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` + AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"` + ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` + RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"` + PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` + PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` + ExternalTransactionNo *string `gorm:"column:external_transaction_no;type:varchar(128);comment:线下充值交易流水号(人工确认,独立于在线渠道第三方交易号)" json:"external_transaction_no,omitempty"` + OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id;index;comment:线下收款方式字典ID(关联tb_employee_collection_payment_method.id)" json:"offline_payment_method_id,omitempty"` + OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64);comment:线下收款方式稳定编码快照" json:"offline_payment_method_code,omitempty"` + OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100);comment:线下收款方式名称快照" json:"offline_payment_method_name,omitempty"` + OtherVoucherKeys StringJSONBArray `gorm:"column:other_voucher_keys;type:jsonb;comment:其他凭证对象存储Key列表(可选,最多5个)" json:"other_voucher_keys"` + PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"` + PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"` + Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` + RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"` + RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"` + RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"` + PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` } // TableName 指定表名 diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index 34c4ea6..e53c518 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -2,12 +2,15 @@ package dto // CreateAgentRechargeRequest 创建代理充值请求 type CreateAgentRechargeRequest struct { - ShopID *uint `json:"shop_id,omitempty" description:"目标店铺ID,仅平台线下代充可填;代理在线充值禁止传入"` - Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` - PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"` - RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"` - PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"` - Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` + ShopID *uint `json:"shop_id,omitempty" description:"目标店铺ID,仅平台线下代充可填;代理在线充值禁止传入"` + Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"` + RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"` + PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,在线支付时禁止传入)"` + OtherVoucherKey []string `json:"other_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"其他凭证对象存储Key列表(线下代充可选,最多5个,在线支付时禁止传入)"` + OfflinePaymentMethodID uint `json:"offline_payment_method_id" description:"线下收款方式字典ID(payment_method=offline 时必填,须取自启用中的线下收款方式字典)"` + ExternalTransactionNo string `json:"external_transaction_no" validate:"omitempty,max=128" maxLength:"128" description:"交易流水号(payment_method=offline 时必填,由付款凭证识别预填并经人工确认;系统不做跨记录去重)"` + Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` } // AgentRechargeOnlineResponse 代理在线扫码充值创建响应。 @@ -45,6 +48,29 @@ type AgentRechargePaymentStatusResponse struct { CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"` } +// AgentSelfRechargePaymentMethodsUpdateRequest 修改代理在线自充允许范围请求。 +// 枚举取值必须在 description 与 enum 标签两处与 pkg/constants 的 AgentSelfRechargeAllowed* 保持一致。 +type AgentSelfRechargePaymentMethodsUpdateRequest struct { + AllowedMethods string `json:"allowed_methods" validate:"required,oneof=wechat_only alipay_only both" required:"true" enum:"wechat_only,alipay_only,both" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"` +} + +// AgentSelfRechargePaymentMethodsUpdateResponse 修改代理在线自充允许范围响应。 +type AgentSelfRechargePaymentMethodsUpdateResponse struct { + AllowedMethods string `json:"allowed_methods" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"` + AllowedMethodsName string `json:"allowed_methods_name" description:"允许范围名称(中文)"` + UpdatedAt string `json:"updated_at" description:"最近更新时间,带时区 RFC3339 格式"` +} + +// AgentRechargePaymentVoucherOCRRequest 付款凭证识别请求。 +type AgentRechargePaymentVoucherOCRRequest struct { + PaymentVoucherKey string `json:"payment_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"付款凭证对象存储Key,必须指向已上传的图片类型附件"` +} + +// AgentRechargePaymentVoucherOCRResponse 付款凭证识别响应,只返回交易流水号预填值。 +type AgentRechargePaymentVoucherOCRResponse struct { + ExternalTransactionNo string `json:"external_transaction_no" description:"识别出的交易流水号预填值,必须经人工确认或更正后提交;金额、备注、付款人、支付方式与支付时间一律不返回"` +} + // AgentOfflinePayRequest 代理线下充值确认请求 type AgentOfflinePayRequest struct { OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"` @@ -58,33 +84,39 @@ type AgentOfflinePayParams struct { // AgentRechargeResponse 代理充值记录响应 type AgentRechargeResponse struct { - ID uint `json:"id" description:"充值记录ID"` - RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"` - ShopID uint `json:"shop_id" description:"店铺ID"` - ShopName string `json:"shop_name" description:"店铺名称"` - AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"` - Amount int64 `json:"amount" description:"充值金额(分)"` - PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"` - RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"` - RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` - PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"` - PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"` - PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"` - PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"` - Remark string `json:"remark,omitempty" description:"运营备注"` - Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"` - SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` - SubmitterName string `json:"submitter_name" description:"提交人账号名称"` - ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID,在线充值为null"` - ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企微审批为wecom"` - ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` - ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"` - PaidAt *string `json:"paid_at" description:"支付时间"` - CompletedAt *string `json:"completed_at" description:"完成时间"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"充值记录ID"` + RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"` + ShopID uint `json:"shop_id" description:"店铺ID"` + ShopName string `json:"shop_name" description:"店铺名称"` + AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"` + Amount int64 `json:"amount" description:"充值金额(分)"` + PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"` + RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"` + RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` + PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"` + PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"` + PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号(在线渠道权威值,仅在线支付有值)"` + // ExternalTransactionNo 是线下充值人工确认的交易流水号,独立于在线渠道第三方交易号。 + ExternalTransactionNo string `json:"external_transaction_no" description:"交易流水号(线下充值人工确认值,独立于在线渠道第三方交易号)"` + OfflinePaymentMethodID uint `json:"offline_payment_method_id,omitempty" description:"线下收款方式字典ID,仅线下充值有值"` + OfflinePaymentMethodCode string `json:"offline_payment_method_code,omitempty" description:"线下收款方式稳定编码快照,仅线下充值有值"` + OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式名称快照,仅线下充值有值"` + OtherVoucherKey []string `json:"other_voucher_key" description:"其他凭证对象存储Key列表,仅线下充值有值;不返回凭证内容"` + PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"` + Remark string `json:"remark,omitempty" description:"运营备注"` + Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID,在线充值为null"` + ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企微审批为wecom"` + ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"` + PaidAt *string `json:"paid_at" description:"支付时间"` + CompletedAt *string `json:"completed_at" description:"完成时间"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // AgentRechargeRejectRequest 驳回代理充值订单请求 diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 1e878ff..c92858c 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -135,6 +135,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd registerEmployeeCollectionApplicationRoutes(authGroup, handlers.EmployeeCollection, doc, basePath) } if handlers.AgentRecharge != nil { + registerAgentSelfRechargePaymentMethodRoutes(authGroup, handlers.AgentRecharge, doc, basePath) registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath) } if handlers.Refund != nil { diff --git a/internal/routes/agent_recharge.go b/internal/routes/agent_recharge.go index 3dd637d..d8358ca 100644 --- a/internal/routes/agent_recharge.go +++ b/internal/routes/agent_recharge.go @@ -45,6 +45,15 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar Auth: true, }) + Register(group, doc, groupPath, "POST", "/payment-voucher-ocr", handler.PaymentVoucherOCR, RouteSpec{ + Summary: "识别付款凭证中的交易流水号", + Description: "按付款凭证对象键调用识别能力,只返回交易流水号预填值供人工确认或更正;识别结果不写入任何资金事实,失败不阻断人工填写。", + Tags: []string{"代理预充值"}, + Input: new(dto.AgentRechargePaymentVoucherOCRRequest), + Output: new(dto.AgentRechargePaymentVoucherOCRResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/:id/payment-status", handler.PaymentStatus, RouteSpec{ Summary: "查询代理充值本地支付与到账状态", Tags: []string{"代理预充值"}, diff --git a/internal/routes/agent_self_recharge_payment_method.go b/internal/routes/agent_self_recharge_payment_method.go new file mode 100644 index 0000000..6b866fc --- /dev/null +++ b/internal/routes/agent_self_recharge_payment_method.go @@ -0,0 +1,49 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerAgentSelfRechargePaymentMethodRoutes 注册代理自充允许范围读取与修改路由。 +// 读取对代理与平台账号开放,返回的是允许范围与可用商户池的交集;修改仅超级管理员可用。 +// 该路径不在代理充值业务组内,因此读取与写入的角色门禁在此显式声明。 +func registerAgentSelfRechargePaymentMethodRoutes(router fiber.Router, handler *admin.AgentRechargeHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/agent-self-recharge-payment-methods", func(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if c.Method() == fiber.MethodPut { + if userType != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "仅超级管理员可以修改代理自充允许范围") + } + return c.Next() + } + if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "仅代理或平台账号可以查询代理自充可用支付方式") + } + return c.Next() + }) + groupPath := basePath + "/agent-self-recharge-payment-methods" + + Register(group, doc, groupPath, "GET", "", handler.SelfRechargePaymentMethods, RouteSpec{ + Summary: "查询代理自充实际可用支付方式", + Description: "返回超级管理员允许范围与当前可用商户池支付方式的交集,固定顺序;交集为空时返回空列表且不报错。响应不含允许范围本身、商户身份或凭证。", + Tags: []string{"代理预充值"}, + Output: new(dto.AgentRechargePaymentMethodsResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "PUT", "", handler.UpdateSelfRechargePaymentMethods, RouteSpec{ + Summary: "修改代理在线自充允许范围", + Description: "仅超级管理员可修改,取值仅限仅微信、仅支付宝、同时支持;每次修改记录操作者、修改前后值与时间,且只影响后续新单。", + Tags: []string{"代理预充值"}, + Input: new(dto.AgentSelfRechargePaymentMethodsUpdateRequest), + Output: new(dto.AgentSelfRechargePaymentMethodsUpdateResponse), + Auth: true, + }) +} diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index affb0b5..77603e7 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -132,13 +132,16 @@ func (s *Service) createOffline( return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置") } result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{ - SubmitterAccountID: userID, - SubmitterUserType: userType, - ShopID: *req.ShopID, - RechargeNo: rechargeNo, - Amount: req.Amount, - PaymentVoucherKeys: req.PaymentVoucherKey, - Remark: req.Remark, + SubmitterAccountID: userID, + SubmitterUserType: userType, + ShopID: *req.ShopID, + RechargeNo: rechargeNo, + Amount: req.Amount, + PaymentVoucherKeys: req.PaymentVoucherKey, + OtherVoucherKeys: req.OtherVoucherKey, + OfflinePaymentMethodID: req.OfflinePaymentMethodID, + ExternalTransactionNo: req.ExternalTransactionNo, + Remark: req.Remark, }) if err != nil { return nil, err @@ -602,6 +605,7 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe RechargeSource: rechargeSource, RechargeSourceName: rechargeSourceName, PaymentVoucherKey: []string(record.PaymentVoucherKey), + OtherVoucherKey: []string(record.OtherVoucherKeys), Remark: record.Remark, Status: record.Status, StatusName: constants.GetRechargeStatusName(record.Status), @@ -621,6 +625,18 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe if record.PaymentTransactionID != nil { resp.PaymentTransactionID = *record.PaymentTransactionID } + if record.ExternalTransactionNo != nil { + resp.ExternalTransactionNo = *record.ExternalTransactionNo + } + if record.OfflinePaymentMethodID != nil { + resp.OfflinePaymentMethodID = *record.OfflinePaymentMethodID + } + if record.OfflinePaymentMethodCode != nil { + resp.OfflinePaymentMethodCode = *record.OfflinePaymentMethodCode + } + if record.OfflinePaymentMethodName != nil { + resp.OfflinePaymentMethodName = *record.OfflinePaymentMethodName + } if record.PaidAt != nil { t := record.PaidAt.Format("2006-01-02 15:04:05") resp.PaidAt = &t diff --git a/migrations/000213_add_agent_recharge_offline_fields.down.sql b/migrations/000213_add_agent_recharge_offline_fields.down.sql new file mode 100644 index 0000000..282d11b --- /dev/null +++ b/migrations/000213_add_agent_recharge_offline_fields.down.sql @@ -0,0 +1,25 @@ +-- 回滚代理线下预存款审批字段。 +-- 新增列仍承载真实人工确认事实时禁止回滚:删除后交易流水号、收款方式快照与其他凭证引用无法恢复。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_recharge_record + WHERE external_transaction_no IS NOT NULL + OR offline_payment_method_id IS NOT NULL + ) THEN + RAISE EXCEPTION '存在仍被引用的线下充值收款方式、交易流水号或其他凭证,禁止回滚代理线下预存款审批字段迁移'; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_agent_recharge_external_transaction_no; +DROP INDEX IF EXISTS idx_agent_recharge_offline_payment_method; + +-- 仅删除本次新增列,不影响既有充值记录读取与在线渠道列。 +ALTER TABLE tb_agent_recharge_record + DROP COLUMN IF EXISTS other_voucher_keys, + DROP COLUMN IF EXISTS offline_payment_method_name, + DROP COLUMN IF EXISTS offline_payment_method_code, + DROP COLUMN IF EXISTS offline_payment_method_id, + DROP COLUMN IF EXISTS external_transaction_no; diff --git a/migrations/000213_add_agent_recharge_offline_fields.up.sql b/migrations/000213_add_agent_recharge_offline_fields.up.sql new file mode 100644 index 0000000..c0d1a6f --- /dev/null +++ b/migrations/000213_add_agent_recharge_offline_fields.up.sql @@ -0,0 +1,26 @@ +-- 代理线下预存款审批字段:收款方式快照、交易流水号与其他凭证。 +-- 新增列保持可空以兼容历史行;不新增唯一约束,交易流水号不参与去重。 +-- external_transaction_no 独立于在线渠道的 payment_transaction_id,两者语义与写入方互不影响。 + +ALTER TABLE tb_agent_recharge_record + ADD COLUMN IF NOT EXISTS external_transaction_no varchar(128), + ADD COLUMN IF NOT EXISTS offline_payment_method_id bigint, + ADD COLUMN IF NOT EXISTS offline_payment_method_code varchar(64), + ADD COLUMN IF NOT EXISTS offline_payment_method_name varchar(100), + ADD COLUMN IF NOT EXISTS other_voucher_keys jsonb; + +-- 收款方式引用判定与详情查询索引:仅索引有效引用行。 +CREATE INDEX IF NOT EXISTS idx_agent_recharge_offline_payment_method + ON tb_agent_recharge_record (offline_payment_method_id) + WHERE offline_payment_method_id IS NOT NULL AND deleted_at IS NULL; + +-- 交易流水号检索索引:不是唯一约束,允许重复申报。 +CREATE INDEX IF NOT EXISTS idx_agent_recharge_external_transaction_no + ON tb_agent_recharge_record (external_transaction_no) + WHERE external_transaction_no IS NOT NULL AND external_transaction_no <> '' AND deleted_at IS NULL; + +COMMENT ON COLUMN tb_agent_recharge_record.external_transaction_no IS '线下充值交易流水号,由付款凭证识别预填并经人工确认,独立于在线渠道第三方交易号'; +COMMENT ON COLUMN tb_agent_recharge_record.offline_payment_method_id IS '线下收款方式字典ID(关联tb_employee_collection_payment_method.id)'; +COMMENT ON COLUMN tb_agent_recharge_record.offline_payment_method_code IS '线下收款方式稳定编码快照,字典改名或停用不影响历史记录'; +COMMENT ON COLUMN tb_agent_recharge_record.offline_payment_method_name IS '线下收款方式名称快照,字典改名或停用不影响历史记录'; +COMMENT ON COLUMN tb_agent_recharge_record.other_voucher_keys IS '其他凭证对象存储Key列表(jsonb 存储 []string),只保存对象键引用,最多5个'; diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/design.md b/openspec/changes/add-agent-self-recharge-payment-methods/design.md index e3cee53..0dbcc49 100644 --- a/openspec/changes/add-agent-self-recharge-payment-methods/design.md +++ b/openspec/changes/add-agent-self-recharge-payment-methods/design.md @@ -1,26 +1,140 @@ +## Context + +现有代理在线充值链路已完整可用,本 Change 只在其外层补"允许范围"与"申报字段",不改造支付与入账闭环: + +- 创建与门禁:`POST /api/admin/agent-recharges` → `admin.AgentRechargeHandler.Create`,在线分支为 `createOnline` 并显式拒绝 `shop_id`、凭证与备注(`internal/handler/admin/agent_recharge.go:69-85`);用例为 `agentrecharge.OnlineCreationService.Execute`(`internal/application/agentrecharge/online_creation.go:64`)。 +- 自身店铺门禁已内联在 `loadCreationFacts`:账号 `user_type=agent ∧ status=1 ∧ account.shop_id == CurrentShopID`、店铺启用、主钱包 `main+normal`(`internal/application/agentrecharge/online_creation.go:174-200`)。店铺取自认证上下文,客户端无法指定。 +- 可用方式当前只按商户池推导:`OnlineCreationService.AvailablePaymentMethods` 遍历固定顺序 `[wechat, alipay]`,过滤启用池 + 启用商户 + `MerchantConfig` + `adapter.Available`(`internal/application/agentrecharge/online_creation.go:131-172`),且对非代理直接 403。 +- 商户选路与快照冻结在创建事务内完成:`merchantpayment.SelectForNewPaymentWithTx` 与 `FreezeRoute`(`internal/application/merchantpayment/routing.go:203,357`)。无可用商户时返回 `CodeNoPaymentConfig`,归档契约明确禁止回退旧综合支付配置。 +- 受控系统配置已具备完整写路径:`PUT /api/admin/system-configs/:key` → `systemconfig.UpdateService.Execute`,仅超级管理员、`pg_advisory_xact_lock` 串行、首次写入即 upsert、前后值审计、提交后缓存失效(`internal/application/systemconfig/update.go:72,104,110-140`)。Registry 原生支持 `ValueType=string + EnumValues`(`internal/infrastructure/systemconfig/registry.go:104-140`);`Reader.GetStrict` 在无行时返回代码注册的 `DefaultValue`,已落库的非法值失败关闭(`internal/infrastructure/systemconfig/reader.go:118-143`)。 +- 线下预存款申请事实就是 `tb_agent_recharge_record`(`payment_method='offline'`),审批实例一业务单一个(`uq_approval_instance_business`,`migrations/000170_create_approval_core.up.sql:18`),驳回即终态、无重提。创建用例 `OfflineCreationService.Execute` 与命令 `CreateOfflineCommand` 只含 `ShopID/Amount/PaymentVoucherKeys/Remark`(`internal/application/agentrecharge/offline_creation.go:21-29`)。现表无收款方式引用、无交易流水号、无其他凭证列(`internal/model/agent_wallet.go:76-100`)。 +- 场景可映射字段是白名单硬编码:`offline_recharge_approval` 现有 9 个字段,不含收款方式与其他凭证(`internal/application/wecom/scene.go:309-320`)。 +- 线下收款方式字典已由员工账单 Change 建立并被其声明为共享分类:`tb_employee_collection_payment_method`(`migrations/000212_add_employee_collection_bills.up.sql:6-32`),契约明确"由本能力独占维护,MUST NOT 被定义为核销专用"(`openspec/specs/employee-collection-bill/spec.md`)。当前字典读写与"被引用"判定只在 `internal/application/employeecollection/payment_method.go` 内,且只扫核销申请与尝试。 +- Gateway 客户端已具备 AES-128-ECB 加密、MD5 签名、网络级重试与统一错误映射(`internal/gateway/client.go`、`crypto.go`;契约见 `docs/integrations/gateway/README.md`)。附件对象存储提供按 key 读取:`Provider.Download`、`DownloadToTemp`、`Stat`、`Exists`(`pkg/storage/storage.go:12-18`)。 +- 主 spec 中 `agent-funds-commission` 的"代理在线充值可用支付方式按支付配置判定"仍描述商户池改造前的旧口径,与本 Change 的目标判定冲突,需一并修正。 + +## Goals / Non-Goals + +**Goals:** + +- 让代理在线自充可用方式成为"超管允许范围 ∩ 可用商户池方式",且允许范围对代理与平台不可见。 +- 让线下预存款申请按 §17.1 留存可维护的收款方式、交易流水号与其他凭证。 +- 以既有 Gateway 通道提供付款凭证识别预填,且识别结果不成为资金事实、不进入日志。 + +**Non-Goals:** + +- 不新增代理自助线下充值入口,不改造 `offline_recharge_approval` 的一业务单一个审批实例语义与终态推进。 +- 不引入审批尝试记录模型,不实现驳回后重提。 +- 不改造员工账单建账判据、核销申请流程与退款冲销。 +- 不新建业务字典模块或收款账户目录。 +- 不改造 Gateway 客户端的加密、签名、重试与错误映射;不改动其既有能力文件。 + ## Decisions -- 线上充值复用支付单和实际商户路由,成功消费者以支付单/钱包流水唯一约束入账。 -- 线下申请保存不可变金额和付款证据快照;审批回调在主钱包锁事务中条件入账。 -- 线上未知结果和线下审批未知均保持在途,复用既有查询/恢复,不把重试当作新入账。 +### 1. 允许范围落在受控系统配置,不新建表 -## 配置与充值动作契约 +新增一个注册 Key,`ValueType=string`、`EnumValues` 为三个稳定取值(仅微信 / 仅支付宝 / 同时支持),默认值为"同时支持"以保持既有行为。读写复用既有 `systemconfig.UpdateService` 与 `Reader.GetStrict`,从而直接获得超管限定、咨询锁串行、前后值审计、枚举校验与缓存失效,无需新表或第二套配置约定。 -### 允许方式与查询 +替代方案(新建专用配置表)被拒绝:会与既有受控配置重复审计与缓存语义,且 `GET /system-configs` 已限定超级管理员读取(`internal/query/systemconfig/list.go:29-31`),天然满足"允许范围不得泄漏给代理与平台"。 -- `GET /agent-self-recharge-payment-methods`:代理、平台用户仅返回“全局允许方式 ∩ 当前启用商户池可用方式”的有序 `wechat`/`alipay` 列表;不得返回商户身份、凭证或全局允许范围。交集为空返回空列表。 -- `PUT /agent-self-recharge-payment-methods`:仅超级管理员,保存 `wechat_only`、`alipay_only` 或 `wechat_and_alipay`;记录操作者、前后值和时间。修改不更新任何已有充值/支付单的支付方式、商户 ID 或快照。 +管理端点按需求命名为 `GET/PUT /agent-self-recharge-payment-methods`,实际注册为 `/api/admin` 下的独立单段路由组(`internal/routes/agent_self_recharge_payment_method.go`)。需求路径与既有代理充值组前缀 `/api/admin/agent-recharges` 不同,无法挂在该组下,因而也无法继承该组「企业账号 403」的门禁;读取与写入的角色门禁改由该路由组内显式声明:读取仅代理与平台账号,写入仅超级管理员且显式拒绝企业账号。写入仍复用同一配置写服务,不新增第二条配置写路径。 -### 自身店铺充值 +### 2. 交集在读侧与创建侧各判定一次,两侧均失败关闭 -- `POST /agent-recharges` 的代理在线分支强制 `shop_id` 为空且认证店铺为已启用代理自身店铺;传入下级/其他店铺返回无权。线上请求 `amount`、`payment_method`、`request_id`,其中方式必须在当前交集内;以 `request_id` 与调用者/店铺唯一复用既有支付创建结果,失败预下单不入账。 -- 在线创建在事务内选择实际商户、冻结支付/商户快照并创建充值记录;渠道成功消费者锁定支付、充值和主钱包,以支付 ID/钱包流水唯一约束一次入账。失败、关闭、退款或未知状态不加余额;未知结果由既有查单/回调恢复,禁止客户端重试直接增加余额。 +读侧 `AvailablePaymentMethods` 增加允许范围过滤,并把可见角色从"仅代理"放开为"代理与平台账号";创建侧在 `Execute` 的域校验之后、读取店铺事实之前,用同一次配置读取结果判定请求方式是否在交集内,不在则拒绝。 -### 线下转账 +只做创建侧校验会让代理看到无法使用的方式;只做读侧校验会让绕过查询的请求创建被禁用方式。两侧都必须走 `GetStrict`,配置非法时失败关闭,而不是回退默认值后继续放行。 -- 线下请求必须包含 `amount`(正分)、`payer_name`、`transferred_at`(带时区时间)、`transfer_channel_or_bank`、`transaction_no`、至少一个 `payment_voucher_key` 和可选备注;创建时冻结全部字段、状态为待企业微信审批,不增加主钱包。 -- 企业微信通过消费者锁定申请、审批实例和主钱包,条件更新一次增加余额和钱包流水;驳回、撤回、关闭不入账。仅未成功申请可修改上述材料并创建新审批实例重提;未知审批保持在途。平台/超级管理员查询和处理一律先应用既有店铺数据范围,审计不记录凭证正文。 +创建侧允许范围门禁位于幂等回放之后:同一 `request_id` 的重试属于既有单而非新单,不因允许范围变更被拒绝;门禁只拦截会真正新建充值单与支付单的路径,被拒方式仍不新建任何行(`internal/application/agentrecharge/online_creation.go:90-97`)。 + +### 3. 交易流水号使用独立列,不复用在线渠道交易号 + +`tb_agent_recharge_record` 新增 `external_transaction_no` 列保存该笔充值的交易流水号,其值来自付款凭证识别的支付单号并经人工确认或更正。列名与语义对齐既有员工账单能力的外部交易流水号(`tb_employee_collection_application.external_transaction_no`,注释"外部交易流水号,可由 OCR 预填但以人工确认值为准",`migrations/000212_add_employee_collection_bills.up.sql:262`),全仓保持一套词汇,展示名仍为"交易流水号"。 + +**不复用 `payment_transaction_id`**,理由是结构性的而非命名偏好: + +- 该列是在线支付的**渠道权威事实**,只由在线回调写入(`internal/application/agentrecharge/confirm_online_payment.go:127`、`internal/service/agent_recharge/service.go:303`),并作为入账对账键被 Outbox 消费者强校验:`recharge.PaymentTransactionID` 必须等于事件里的渠道交易号,否则返回冲突(`internal/infrastructure/payment/agent_recharge_consumer.go:118`)。一旦允许人工修改该列,在线入账对账即被破坏。 +- 该列同时是财务调查视图的交易流水号检索域(`internal/query/audit/finance.go:480,1007`)与代理充值审计身份字段(`internal/infrastructure/audit/registry.go:795`)。把人工申报值并入,会让渠道权威值与人工申报值在同一检索域内不可区分。 +- 申报值本质是"人工确认的付款证据",渠道值本质是"渠道返回的权威事实",二者来源与可信级别不同,`111.md` §0 第 4 条的"识别结果不是资金事实"要求它们在存储上可区分。 + +替代方案(复用 `payment_transaction_id`)被拒绝,原因如上;替代方案(另建申报表)过度:该字段始终 1:1 属于充值单。 + +不新增唯一约束:同一外部付款的重复申报由企业微信终审人员以第三方记录核验,与员工账单能力对跨申请累计分摊不做系统防重的既有裁决一致(`openspec/specs/employee-collection-bill/spec.md`)。因此线下申报的交易流水号不做跨记录去重,也不作为入账前置条件。 + +在线充值单的 `payment_transaction_id` 语义与写入方保持不变;线下列可空以满足历史行兼容与 down 回滚。 + +### 4. 收款方式以三列快照引用既有字典,命名与支付方式枚举区分 + +`tb_agent_recharge_record` 新增 `offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name` 三列,引用 `tb_employee_collection_payment_method`。刻意不命名为 `payment_method_*`:该表已有 `payment_method` 列表示 `wechat/alipay/bank/offline` 支付方式枚举,同前缀会造成两套语义混淆。快照列与员工账单申请/尝试的 `payment_method_id/code/name` 同构。 + +新建申请时校验字典项存在且启用;历史行三列为空。 + +字典的"被引用"判定需从只扫核销申请与尝试扩展为同时覆盖代理充值申请,否则超管可删除或改码一个仍被引用的字典项,破坏"被引用只可停用"的可观察语义。该扩展是引用判定的最小补齐,不改动员工账单的建账、核销与退款逻辑。 + +### 5. 其他凭证使用独立列 + +新增 `other_voucher_keys`(jsonb 数组,对象键引用),与既有 `payment_voucher_key` 并列,不合并为单一列表:§17.1 要求"支付凭证"与"其他凭证"在审批表单中分别呈现,合并后无法区分两类。 + +### 6. 付款凭证识别新增 Gateway 能力,不复用带响应日志的泛型入口 + +在 `internal/gateway` 新增能力文件封装 `POST /ai/ocr/extract-payment`,入参为 `image_base64`。图片字节由后端按附件对象键读取对象存储后编码,避免把 Gateway 凭证下发给前端。 + +该能力刻意既不用 `doRequest` 也不用 `doRequestWithResponse`,两条既有路径都会把识别载荷写进日志: + +- `doRequest` 在 Info 级别打印**加密前的完整明文请求体**(`internal/gateway/client.go` 的 `zap.ByteString("body", dataBytes)`),而本能力的请求体正是凭证图片的 base64。 +- `doRequestWithResponse` 在 `doRequest` 之上再于 Info 级别打印**完整原始响应**(同文件的 `zap.ByteString("data", data)`),会把识别出的付款人、金额与单号一并写入日志。 + +因此本能力改用 `Client.doRequestWithoutPayloadLog`:它与 `doRequest` 共用同一套序列化、AES-128-ECB 加密、MD5 签名与网络级重试流程(`executeWithRetry` 的 `logPayload` 分支),只在记录内容上分叉——仅记录路径、耗时与结果字节数摘要,响应由能力文件自行反序列化。既有能力的请求行为与日志语义保持原样,不受本能力影响。 + +识别结果不落库、不写入任何资金事实字段;系统只在请求内向调用方返回支付单号,供交易流水号表单预填。Gateway 返回的其余字段不进入本接口响应。 + +#### 识别结果到表单字段的映射 + +Gateway 识别响应含 `amount`、`order_number`、`payee`、`payment_method`、`payment_time`、`remark`,但本 Change 只消费和返回一个字段: + +| 识别字段 | 表单字段 | 处置 | +| --- | --- | --- | +| `order_number` | 交易流水号(`external_transaction_no`) | 唯一预填字段;§17.1 的“交易流水号”即此值 | +| `amount` | 无 | 不预填、不返回 | +| `remark` | 无 | 不预填、不返回 | +| `payment_time` | 无 | 不预填、不返回 | +| `payment_method` | 无 | 不预填、不返回 | +| `payee` | 无 | 不预填、不返回 | + +`payment_method` 不可映射到收款方式字典:它描述付款方使用的支付工具(如某银行信用购),而 §17.1 的收款方式是公司侧业务维护的收款账户类目。`payee` 也不可映射到付款方:字段名意为收款人,示例值为公司名称,但外部文档描述写作“付款人”;§17.1 的付款方口径则是“对应店铺”。即使未来扩大 OCR 返回字段,二者仍不得自动映射。 + +识别值仅作预填,实测可能不完整(上游对长号码存在只返回部分位数的情况,见 `docs/integrations/gateway/README.md` 的限制说明),必须由提交人人工核对后以人工确认值作为业务事实;系统不得将识别值当作可信的完整流水号使用,也不得据此做任何自动比对或防重。 + +### 7. 识别接口面向本 Change 场景,并做附件归属校验 + +新增 `POST /api/admin/agent-recharges/payment-voucher-ocr`,落在既有代理充值路由组(组级已拒绝企业账号),入参为附件对象键。创建申请前调用,因此不带充值单 ID。 + +接口对每个对象键校验对象存在且为非空,并以对象元数据判定为图片类型;非图片或对象不存在返回明确失败,不阻断人工填写。既有线下与核销路径只校验键的数量与长度、不校验归属,此处不扩大该缺口,但至少保证不因识别接口把任意对象内容回显为字段。 + +### 8. OCR 预填必须在创建申请前可人工更正 + +交易流水号是付款凭证识别的预填值,不是 OCR 写入的冻结值。提交人必须能在创建线下预存款申请前修改 `order_number` 预填的交易流水号;创建时保存人工确认后的 `external_transaction_no`,并按既有申请创建审计记录操作者与最终值。 + +本 Change 不新增在企业微信审批已提交后原地修改材料的语义:审批表单由 Worker 在提交时从 `tb_approval_instance.request_snapshot` 渲染(`internal/infrastructure/wecom/approval_submission_consumer.go:51-52`)。已被企业微信受理的表单不可由本地字段覆盖,否则本地记录与审批人看到的材料不一致。若后续需要“提交后修正且审批人看到修正值”,必须以新审批实例重提,届时再独立引入审批尝试记录与重提状态机,不能在本 Change 伪造同步修改。 + +## Risks / Trade-offs + +- [识别返回的 `payment_method` 与收款方式字典语义不同] → 识别返回的支付方式描述的是付款方使用的支付工具(如某银行信用购),§17.1 的收款方式是公司侧业务维护的收款账户类目。设计明确二者不可互相自动映射:收款方式必须由提交人从字典中选择,识别值只可作提示,不得自动命中字典项。 +- [重复交易流水号] → 交易流水号是人工确认的申报值而非系统去重键,不设唯一约束、不做跨记录防重,与员工账单能力对跨申请累计分摊不做系统防重的既有裁决一致;重复申报由企业微信终审人员以第三方记录核验。 +- [base64 图片体积导致请求过大或超时] → 单次识别只接受单个附件键;复用既有 Gateway 超时配置与网络级重试;识别失败返回明确失败而不阻断人工填写。 +- [识别接口放大对象读取面] → 校验对象存在、非空且为图片类型;仅返回识别字段,不返回对象内容;接口不承诺资源级附件鉴权,与既有附件契约一致。 +- [字典引用判定扩展触及员工账单代码] → 只扩展引用查询范围,不改建账判据、核销申请、分摊或退款冲销;变更点集中在字典删除与改码两处校验。 +- [修正主 spec 的可用方式 Requirement 可能与其他 Change 冲突] → 该 Requirement 描述的是商户池改造前的旧口径,与实际实现和归档商户池契约都冲突;本 Change 以 RENAMED(改名)+ MODIFIED(全量替换)同步,保留既有四个场景名以免归档时丢弃场景,并按商户池口径重写其内容。 ## Migration Plan -新增线下充值申请、附件快照、审批关联及唯一约束的成对迁移;验证权限、回调重放、未知、驳回重提和 up/down/up。 \ No newline at end of file +1. 新增成对迁移(`.up.sql`/`.down.sql`)为 `tb_agent_recharge_record` 增加 `external_transaction_no`、收款方式三列与 `other_voucher_keys`,并为其补充注释与查询索引;不修改任何既有迁移。在线渠道列 `payment_transaction_id` 的定义与写入方保持不变。迁移编号在实施开始前按当时 `migrations/` 目录最大编号顺延,不预占。 +2. 新增列的线下必填由应用层保证,数据库列保持可空以兼容历史行,使 down 迁移无需清理数据即可回滚。 +3. 不新增业务类型、不扩展 `tb_wecom_approval_scene` 的业务类型 CHECK、不新增审批场景配置行;本 Change 只扩展 `offline_recharge_approval` 场景的可映射业务字段白名单,需由超级管理员在既有场景配置中把新字段映射到企业微信模板控件后才在详情中可见。 +4. 允许范围未配置时使用代码注册默认值,因此不存在需要迁移写入的基准数据;配置由超级管理员按需设置。 +5. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture,禁止重置整库;仅连接、迁移或实际行为失败时才阻塞对应场景。验证项见 tasks。 +6. 回滚:仅在本 Change fixture 已清理、且维护者确认新增收款方式、交易流水号与凭证快照没有留存需求时,先停用新入口,再执行 down 删除新增列。允许范围配置行保留且不影响旧版本行为;down 不得影响既有充值记录读取。存在仍需保留的真实新增列数据时,禁止将 down 作为正常回滚场景执行;MUST NOT 通过重置整个 `junhong_cmp_test` 规避该边界。 + +## Open Questions + +无。识别接口的字段规模、置信度与失败分级以 `docs/integrations/gateway/README.md` 的契约为准;契约变化时更新该文档,不影响本 Change 的规格与任务拆分。 diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/proposal.md b/openspec/changes/add-agent-self-recharge-payment-methods/proposal.md index c15f449..c452d3d 100644 --- a/openspec/changes/add-agent-self-recharge-payment-methods/proposal.md +++ b/openspec/changes/add-agent-self-recharge-payment-methods/proposal.md @@ -1,25 +1,43 @@ ## Scope - 迭代编号:`AUG26-017`。 +- 需求出处: + - `111.md` §25(PRD-008-021 代理自充收款方式可配置)与讨论稿 §2.16 第 3 段:超管维护允许范围、与商户池方式取交集、只读交集、配置不影响已创建未支付单。 + - 讨论稿 §2.16 第 4 段:代理充值记录新增交易流水号字段。 + - `111.md` §17.1(预存款审批字段)与讨论稿 §2.1 第 5 点:预存款审批选择线下收款方式字典项并冻结名称快照;讨论稿 §0.1 已将该子节归入 AUG26-017。 + - 讨论稿 §0 第 4 条与 §2.1 第 3 点:OCR 仅作为交易流水号的预填来源,人工确认值才是业务事实。 ## Why -代理自助充值需同时覆盖线上支付和可审计的线下转账,且不能将付款成功与钱包入账混淆。 +代理在线自充当前只能由商户池可用性推导可用方式,平台无法收窄代理可选范围;需求方要求超级管理员可配置"仅微信 / 仅支付宝 / 同时支持",并使代理实际可用方式等于该范围与商户池方式的交集。同时线下预存款审批缺少可维护的公司收款方式与交易流水号,无法按 §17.1 完整留存付款事实。 ## What Changes -- 新增代理自身店铺线上微信/支付宝充值。 -- 新增带凭证的线下转账申请及企业微信终审。 -- 固化支付回调、审批回调和钱包入账幂等。 +- 新增超级管理员可维护的代理在线自充允许方式,取值仅微信、仅支付宝、同时支持三种之一;修改记录操作者、前后值与时间。 +- 代理实际可用线上方式改为"允许范围 ∩ 当前可用商户池支付方式",查询返回该交集的有序 `wechat`/`alipay` 列表,不返回商户身份、凭证或允许范围本身;代理与平台账号均可查询。 +- 代理以某方式创建在线充值单时校验该方式在交集内;交集为空时查询返回空列表并拒绝创建,不回退任何历史支付配置。配置变更只影响后续新单,已创建未支付单保留其支付方式与商户路由快照。 +- 代理充值记录新增交易流水号,保存该笔充值对应的交易流水号。 +- 线下预存款审批补齐收款方式(引用既有线下收款方式字典并冻结标识、编码、名称快照)、其他凭证与交易流水号,并接入 Gateway 付款凭证识别作为预填能力;识别结果不是资金事实。 ## Capabilities ### New Capabilities -- `agent-self-recharge-payment`: 代理自助充值支付方式。 + +无。 ### Modified Capabilities -- 无。 + +- `agent-funds-commission`:代理在线充值可用支付方式由"按当前生效支付配置判定"改为"按超级管理员允许范围与可用商户池方式的交集判定",并新增允许范围维护与审计、交集查询与创建门禁、充值记录交易流水号、线下预存款审批收款方式与其他凭证、付款凭证识别预填,以及线下收款方式字典对代理充值引用的删除与改码保护。 ## Impact -影响代理主钱包、支付商户、企业微信审批、附件、审计和 Schema。 \ No newline at end of file +影响代理在线充值可用方式判定与创建门禁、代理充值记录 Schema、线下预存款企业微信审批场景的业务字段白名单与详情映射、受控系统配置注册、Gateway 外部集成、路由与 OpenAPI 文档。不影响 C 端个人客户能力、员工代收款账单建账判据与核销流程、套餐退款与原路退款。 + +## Non-Goals + +- 不新增代理自助线下转账充值入口;代理商线下充值申请仍由平台账号经办,不改造其创建门禁、状态机或审批实例语义。 +- 不为线下充值引入"驳回后可修改重提"或多审批实例的审批尝试记录模型。 +- 不新建第二份线下收款方式字典,不建设收款账户目录。 +- 不改造员工代收款核销申请的字段与流程,不改动员工账单建账与退款冲销判据。 +- 不实现套餐退款、OCR 之外的第三方契约变更,不新增支付渠道能力。 +- 不实现自动化测试(项目决策为 N/A)。 diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-funds-commission/spec.md b/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-funds-commission/spec.md new file mode 100644 index 0000000..63ddae4 --- /dev/null +++ b/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-funds-commission/spec.md @@ -0,0 +1,170 @@ +## RENAMED Requirements + +- FROM: `### Requirement: 代理在线充值可用支付方式按支付配置判定` +- TO: `### Requirement: 代理在线充值可用支付方式按允许范围与可用商户池判定` + +## MODIFIED Requirements + +### Requirement: 代理在线充值可用支付方式按允许范围与可用商户池判定 + +系统 SHALL 以超级管理员维护的允许范围与当前可用商户池支付方式的交集判定代理在线充值的实际可用支付方式。允许范围 MUST 仅取仅微信、仅支付宝、同时支持三者之一;商户池侧 MUST 沿用"该支付方式存在启用商户池且存在启用、凭证完整且当前适配器可用的商户成员"的既有判定。对外支付方式枚举 MUST 固定为 `wechat` 与 `alipay`,MUST NOT 返回 `fuiou`、商户身份、凭证或允许范围本身。代理账号与平台账号均可查询该交集列表,查询 MUST 返回有序列且不因交集为空而报错;交集为空时创建对应方式的在线充值单 MUST 被拒绝且 MUST NOT 新建充值单或支付单,MUST NOT 回退任何历史生效支付配置。代理以 `wechat` 创建在线充值单时,若命中的商户为富友服务商,系统 MUST 使用富友主扫统一下单并将返回的二维码链接作为支付链接。允许范围变更 MUST 只影响后续新单;已创建未支付充值单 MUST 保留其支付方式与商户路由快照。 + +#### Scenario: 富友配置完整时微信可用 + +- **GIVEN** 允许范围包含微信,且微信商户池存在启用、凭证完整的富友服务商商户成员 +- **WHEN** 代理账号查询可用支付方式 +- **THEN** 系统返回包含 `wechat` 的方式列表且不包含 `fuiou` + +#### Scenario: 微信直连配置完整时微信可用 + +- **GIVEN** 允许范围包含微信,且微信商户池存在启用、凭证完整的微信直连商户成员 +- **WHEN** 代理账号查询可用支付方式 +- **THEN** 系统返回包含 `wechat` 的方式列表 + +#### Scenario: 支付宝字段完整时支付宝可用 + +- **GIVEN** 允许范围包含支付宝,且支付宝商户池存在启用、凭证完整的商户成员 +- **WHEN** 代理账号查询可用支付方式 +- **THEN** 系统返回包含 `alipay` 的方式列表 + +#### Scenario: 富友配置下微信创建走主扫下单 + +- **GIVEN** 允许范围包含微信,且微信商户池命中的商户为富友服务商且字段完整 +- **WHEN** 代理账号以 `wechat` 创建在线充值单 +- **THEN** 系统调用富友主扫统一下单并返回二维码链接作为支付链接,本地充值单支付方式为 `wechat`、支付渠道为 `fuiou` + +#### Scenario: 允许范围收窄后代理只看到被允许的方式 + +- **GIVEN** 微信与支付宝商户池均存在可用成员 +- **WHEN** 超级管理员将允许范围设置为仅支付宝,代理账号随后查询可用支付方式 +- **THEN** 系统只返回 `alipay` + +#### Scenario: 交集为空 + +- **GIVEN** 允许范围只包含微信,而当前微信商户池不存在可用成员 +- **WHEN** 代理账号查询可用支付方式并尝试以 `wechat` 创建在线充值单 +- **THEN** 查询返回空列表且不报错,创建被拒绝且不新建充值单或支付单,不发生任何历史配置回退 + +#### Scenario: 配置变更不影响已创建未支付单 + +- **GIVEN** 代理已创建待支付在线充值单并冻结了支付方式与商户路由快照 +- **WHEN** 超级管理员修改允许范围 +- **THEN** 该充值单的支付方式与商户快照保持不变,仅后续新单按新范围判定 + +#### Scenario: 平台账号可查询且看不到允许范围 + +- **GIVEN** 请求账号为平台账号 +- **WHEN** 查询可用支付方式 +- **THEN** 系统返回与代理一致的交集列表,且响应不含允许范围本身、商户身份或凭证 + +#### Scenario: 非超级管理员不能读取或修改允许范围 + +- **GIVEN** 请求账号为代理或平台账号 +- **WHEN** 读取或修改全局允许范围 +- **THEN** 系统拒绝访问且不返回允许范围取值 + +## ADDED Requirements + +### Requirement: 代理自充允许方式配置可审计 + +系统 SHALL 允许超级管理员读取并修改代理在线自充允许方式,取值 MUST 仅限仅微信、仅支付宝、同时支持三种之一;每次成功修改 MUST 记录操作者、修改前后值与修改时间。其他角色 MUST NOT 读取或修改该允许范围。允许范围未被修改过时,系统 MUST 使用代码注册的安全默认值,MUST NOT 因缺少配置而拒绝查询或创建。 + +#### Scenario: 超级管理员修改允许范围 + +- **GIVEN** 当前允许范围为同时支持 +- **WHEN** 超级管理员将其修改为仅微信 +- **THEN** 系统保存新值并记录操作者、修改前后值与修改时间 + +#### Scenario: 非法取值被拒绝 + +- **WHEN** 提交三种允许取值之外的任何值 +- **THEN** 系统拒绝保存并保留原值 + +#### Scenario: 未配置时使用默认值 + +- **GIVEN** 允许范围从未被修改 +- **WHEN** 查询实际可用支付方式 +- **THEN** 系统按代码注册的默认允许范围计算交集,不因缺少配置而失败 + +### Requirement: 线下预存款审批收款方式、其他凭证与交易流水号 + +线下代理预存款/主钱包充值申请 SHALL 由提交人选择一项启用的线下收款方式字典项,并保存其标识、稳定编码与名称快照;字典项改名或停用 MUST NOT 改变历史申请已冻结的快照,已停用项 MUST NOT 可被新申请选取。申请 MUST 保存该笔充值对应的交易流水号,其取值来源为付款凭证识别出的支付单号,且 MUST 以人工确认或更正后的值为准;该系统字段 MUST 独立于在线支付由渠道返回的第三方交易号,MUST NOT 因该值影响任何在线支付事实、钱包入账或幂等判定,且 MUST NOT 作为跨记录去重键或入账前置条件。申请 MUST 支持至少一个支付凭证,并 MUST 支持可选的其他凭证;两类凭证均只保存既有附件对象键引用。系统 MUST 拒绝引用不存在或已停用的收款方式创建申请,MUST NOT 因未填写交易流水号而创建申请。 + +#### Scenario: 收款方式快照冻结 + +- **GIVEN** 一笔线下预存款申请引用了某启用的线下收款方式 +- **WHEN** 超级管理员随后修改该字典项名称或将其停用 +- **THEN** 历史申请仍展示提交时冻结的编码与名称,新申请不可再选择该已停用项 + +#### Scenario: 引用不存在或已停用的收款方式被拒绝 + +- **WHEN** 提交的收款方式字典项不存在或已停用 +- **THEN** 系统拒绝创建申请且不保存审批实例 + +#### Scenario: 支付凭证与其他凭证分别留存 + +- **WHEN** 提交人上传支付凭证与若干其他凭证并创建申请 +- **THEN** 系统分别保存支付凭证与其他凭证的对象键引用,且企业微信审批详情可取得两类凭证 + +#### Scenario: 交易流水号留存并可在审批详情取得 + +- **WHEN** 线下预存款申请创建成功 +- **THEN** 该充值记录保存人工确认后的交易流水号,并可在企业微信审批详情中取得 + +#### Scenario: 缺少交易流水号时拒绝创建 + +- **WHEN** 提交人未填写或填写空白交易流水号 +- **THEN** 系统拒绝创建申请且不增加钱包余额 + +#### Scenario: 交易流水号独立于在线渠道交易号 + +- **GIVEN** 一笔线下预存款申请已保存人工确认的交易流水号 +- **WHEN** 向同一充值记录写入或修正该交易流水号 +- **THEN** 在线支付由渠道返回的第三方交易号、支付渠道与支付状态均不受影响,在线充值的入账对账与幂等判定结果不变 + +#### Scenario: 相同交易流水号不被系统拒绝 + +- **WHEN** 两笔不同的线下预存款申请保存了完全相同的交易流水号 +- **THEN** 系统均接受创建,不因该值重复而拒绝或去重 + +### Requirement: 付款凭证识别仅作交易流水号预填 + +系统 SHALL 提供付款凭证识别,以上传附件的对象键请求识别并只返回支付单号供交易流水号表单预填。识别结果 MUST NOT 写入任何资金事实字段;申请最终保存的交易流水号 MUST 以人工确认或更正后的提交值为准。支付金额、备注、付款人、支付方式与支付时间即使由 Gateway 返回,MUST NOT 被本接口返回或自动预填。识别失败 MUST 返回可理解的失败结果且 MUST NOT 阻断人工填写与提交。系统 MUST NOT 在日志、审计或错误响应中记录凭证内容或识别原始结果。 + +#### Scenario: 识别成功仅返回交易流水号预填值 + +- **GIVEN** 提交人已上传一张图片类型的支付凭证并以其对象键请求识别 +- **WHEN** 识别成功 +- **THEN** 系统只返回识别出的支付单号供交易流水号预填,且此时未创建任何充值申请 + +#### Scenario: 识别结果被人工更正 + +- **GIVEN** 识别返回的支付单号与实际不符 +- **WHEN** 提交人更正后提交申请 +- **THEN** 系统保存更正后的值,识别原值不影响任何业务事实 + +#### Scenario: 识别失败不阻断人工填写 + +- **WHEN** 凭证非图片、对象不存在或识别服务失败 +- **THEN** 系统返回明确失败,且提交人仍可人工填写全部字段后提交 + +#### Scenario: 日志不记录识别原始结果 + +- **WHEN** 付款凭证识别被调用并返回结果 +- **THEN** 日志、审计与错误记录只包含业务标识与结果摘要,不包含凭证内容或识别原始结果 + +### Requirement: 线下收款方式字典对代理充值引用的可见性 + +系统 SHALL 使线下收款方式字典的引用保护覆盖引用它的代理充值申请:已被任一代理充值申请引用的字典项 MUST NOT 被物理删除,MUST 只允许停用,且 MUST 保留历史申请的名称快照。字典项稳定编码在存在引用后 MUST NOT 被修改。 + +#### Scenario: 被代理充值引用的字典项不可删除 + +- **GIVEN** 某线下收款方式字典项已被至少一笔代理充值申请引用 +- **WHEN** 超级管理员请求删除该字典项 +- **THEN** 系统拒绝删除并返回只能停用的稳定错误 + +#### Scenario: 被代理充值引用的字典项编码不可修改 + +- **GIVEN** 某线下收款方式字典项已被至少一笔代理充值申请引用 +- **WHEN** 超级管理员请求修改该字典项的稳定编码 +- **THEN** 系统拒绝修改编码,但允许修改名称、排序、启停与备注 diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-self-recharge-payment/spec.md b/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-self-recharge-payment/spec.md deleted file mode 100644 index 21ccea9..0000000 --- a/openspec/changes/add-agent-self-recharge-payment-methods/specs/agent-self-recharge-payment/spec.md +++ /dev/null @@ -1,18 +0,0 @@ -## ADDED Requirements - -### Requirement: 代理自助充值支付方式 -超级管理员 SHALL 维护代理在线自充允许方式:仅微信、仅支付宝或微信和支付宝;代理实际可用线上方式为该允许范围与当前可用对应商户池方式的交集,交集为空时拒绝创建线上充值单。平台用户和代理仅可查询实际可用方式,不得查看或修改允许范围。配置变更只影响后续新单,已创建未支付充值单保留其支付方式及商户快照。系统 SHALL 允许已启用代理在其自身店铺充值入口选择实际可用的线上微信、线上支付宝或线下转账;不得为下级店铺代充。线上方式创建支付单并按实际商户路由,渠道成功回调幂等增加该代理主钱包余额;失败、关闭或未知不得增加余额,未知结果通过既有支付查询/回调恢复,不允许重复支付单入账。 - -线下转账申请必须填写转账金额、付款人、转账时间、银行/支付渠道、流水号和凭证附件;创建后状态为待企业微信审批,不立即入账。企业微信通过时在事务内锁定申请并仅一次增加主钱包余额;驳回、关闭或撤回不入账,代理可修改未成功申请后以新审批实例重提。充值金额以分保存,展示元时两位小数;平台/超级管理员仅可查看和处理其既有数据范围。 - -#### Scenario: 配置与商户池交集为空 -- **WHEN** 超级管理员允许一种线上方式,但该方式没有可用商户池成员 -- **THEN** 代理可用线上方式列表不含该方式,创建该方式充值单被拒绝,已创建未支付单不受影响 - -#### Scenario: 重复线上成功回调 -- **WHEN** 同一线上充值支付成功回调被重复投递 -- **THEN** 系统只增加一次代理主钱包余额并保留幂等支付事实 - -#### Scenario: 线下申请审批驳回 -- **WHEN** 企业微信驳回线下转账充值申请 -- **THEN** 系统不增加钱包余额,并允许代理修改申请后创建新审批实例重提 diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/specs/order-payment-wallet/spec.md b/openspec/changes/add-agent-self-recharge-payment-methods/specs/order-payment-wallet/spec.md deleted file mode 100644 index 1fb02f4..0000000 --- a/openspec/changes/add-agent-self-recharge-payment-methods/specs/order-payment-wallet/spec.md +++ /dev/null @@ -1,8 +0,0 @@ -## ADDED Requirements - -### Requirement: 代理自充支付方式 -系统 SHALL 使代理在线自充可用支付方式等于超级管理员维护的允许范围与当前启用商户池支付方式的交集;交集为空时拒绝创建新充值单。配置变更仅影响后续订单,既有未支付订单保留其支付方式和商户快照。 - -#### Scenario: 规则命中 -- **WHEN** 业务请求或任务满足本需求定义的前置条件 -- **THEN** 系统按上述规则完成处理、保留可追溯事实,并拒绝与状态、权限或幂等约束冲突的重复操作 diff --git a/openspec/changes/add-agent-self-recharge-payment-methods/tasks.md b/openspec/changes/add-agent-self-recharge-payment-methods/tasks.md index f3ed300..5413b04 100644 --- a/openspec/changes/add-agent-self-recharge-payment-methods/tasks.md +++ b/openspec/changes/add-agent-self-recharge-payment-methods/tasks.md @@ -1,9 +1,41 @@ -## 1. 充值实现 -- [ ] 1.1 追踪代理钱包、线上支付、商户路由、线下附件和企业微信审批链路。 -- [ ] 1.2 新增线下申请/快照/审批关联的成对迁移、模型、状态和幂等约束。 -- [ ] 1.3 实现自身店铺门禁、线上支付创建/成功入账恢复、线下申请/企微终审/重提和钱包事务审计。 -- [ ] 1.4 注册路由、OpenAPI及代理/平台查询数据范围。 +## 1. 允许范围与交集 -## 2. 验证 -- [ ] 2.1 隔离库验证支付方式、越权代充、重复回调、未知恢复、线下驳回重提和 up/down/up。 -- [ ] 2.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-agent-self-recharge-payment-methods --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file +- [x] 1.1 在 `pkg/constants/system_config.go` 新增代理在线自充允许方式的配置 Key 与三个稳定取值常量,默认值为同时支持;在 `internal/bootstrap` 按既有支付方式配置注册该 Key,使用 `ValueType=string` 与 `EnumValues` 约束取值。 +- [x] 1.2 实现允许范围的读取封装:以 `systemconfig.Reader.GetStrict` 读取并把取值映射为允许的 `wechat`/`alipay` 集合,配置未落库时使用代码注册默认值,配置非法时失败关闭。 +- [x] 1.3 在 `internal/application/agentrecharge/online_creation.go` 的 `AvailablePaymentMethods` 中把可见角色从仅代理放开为代理与平台账号,并将返回方式改为"允许范围 ∩ 商户池可用方式"的有序列表;交集为空时返回空列表而不报错。 +- [x] 1.4 在 `OnlineCreationService.Execute` 的域校验之后、读取店铺事实之前加入允许范围校验,拒绝不在交集内的支付方式,且不新建充值单或支付单;确认无可用商户时既有 `CodeNoPaymentConfig` 失败路径保持不回退历史支付配置。 +- [x] 1.5 新增 `GET /agent-self-recharge-payment-methods`(代理与平台账号可查实际可用方式)与 `PUT /agent-self-recharge-payment-methods`(仅超级管理员,复用受控配置写服务以获得前后值审计),注册为 `/api/admin` 下的独立单段路由组并在组内显式声明读写角色门禁(需求路径与既有 `/api/admin/agent-recharges` 前缀不同,无法继承该组的企业账号门禁),并确保响应不包含允许范围、商户身份或凭证。 + +## 2. 线下预存款审批字段与 Schema + +- [x] 2.1 新增成对迁移为 `tb_agent_recharge_record` 增加 `external_transaction_no`、`offline_payment_method_id`、`offline_payment_method_code`、`offline_payment_method_name` 与 `other_voucher_keys` 列(含注释与查询索引),保持可空以兼容历史行;不修改任何既有迁移,不新增唯一约束,不改动既有 `payment_transaction_id` 的定义与写入方。 +- [x] 2.2 扩展 `OfflineCreationCommand` 与线下创建用例:接收并校验启用的线下收款方式字典项(不存在或已停用即拒绝)、必填的交易流水号(取值来自识别支付单号并经人工确认)与可选其他凭证,在同一事务保存收款方式三列快照、交易流水号与其他凭证对象键,并保持既有审批实例关联与审计不变;交易流水号不得参与去重或入账前置判定。 +- [x] 2.3 确认交易流水号写入路径与在线渠道事实隔离:`external_transaction_no` 的写入 MUST NOT 触碰 `payment_transaction_id`、`payment_channel`、支付状态与钱包,且不影响 Outbox 入账消费者的对账判定。 +- [x] 2.4 扩展 `internal/application/wecom/scene.go` 中 `offline_recharge_approval` 的可映射业务字段白名单,加入线下收款方式与其他凭证字段,并在审批请求快照中带入对应值;不修改其他场景的字段定义与业务类型 CHECK。 +- [x] 2.5 扩展线下收款方式字典的"被引用"判定,使已被代理充值申请引用的字典项不可物理删除且编码不可修改,仅可停用;不改变员工账单建账、核销申请、分摊与退款冲销逻辑。 +- [x] 2.6 在代理充值详情与列表投影中返回收款方式快照、交易流水号与其他凭证对象键引用,且不返回凭证内容。 + +## 3. 付款凭证识别预填 + +- [x] 3.1 在 `internal/gateway` 新增付款凭证识别能力,封装识别接口并按需传入 `image_base64`;不使用会打印完整响应体的泛型入口,改为自行反序列化并只记录路径、耗时与结果摘要。 +- [x] 3.2 实现应用层识别用例:按附件对象键读取对象存储、校验对象存在非空且为图片类型、编码为 base64 后调用 Gateway,只将 `order_number` 返回为交易流水号预填值;`amount`、`remark`、`payment_method`、`payee` 与 `payment_time` 均不返回、不预填、不写入任何业务字段。识别结果不落库、不写入任何资金事实字段。 +- [x] 3.3 新增 `POST /agent-recharges/payment-voucher-ocr` 路由与 Handler,复用既有代理充值组门禁,成功响应只含交易流水号预填值;非图片、对象不存在或识别服务失败时返回明确失败且不阻断人工填写。 +- [x] 3.4 确认识别调用路径的日志、审计与错误不包含凭证内容、base64 载荷或识别原始结果。 + +## 4. 路由、文档与装配 + +- [x] 4.1 为新增的两个端点补齐可执行路由注册、`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 的占位装配,以及 `pkg/openapi/handlers.go`、`internal/bootstrap/types.go`、`internal/bootstrap/handlers.go` 的 Handler 装配(沿用既有代理充值 Handler 时确认无需新增装配点)。 +- [x] 4.2 按 ENG-DTO-001 补齐请求与响应 DTO 的中文 description、枚举与 `pkg/constants` 一致,金额使用分,时间使用带时区 RFC3339。 +- [x] 4.3 更新 `docs/integrations/gateway/README.md` 的当前实际使用范围与核验证据,纳入付款凭证识别能力;不得写入凭证、密钥或识别样本。 +- [x] 4.4 为允许范围修改复用既有受控配置审计动作并在 Change 内确认审计覆盖;新增的申请创建与识别调用按 ENG-AUDIT-001 归入既有事实类型,不新增重复事实。 + +## 5. 验证 + +- [x] 5.1 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL 与 Redis DB 6 上验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture,禁止重置整库。 +- [x] 5.2 验证允许范围:三态取值可维护并留痕、非法取值被拒、未配置时使用默认值、非超级管理员不可读不可写。 +- [x] 5.3 验证交集:允许范围收窄后查询只返回被允许方式;允许方式无可用商户池成员时查询返回空列表且创建被拒绝、不新建充值单或支付单;配置变更后已创建未支付单的支付方式与商户快照不变。 +- [x] 5.4 验证线下字段:引用不存在或已停用收款方式被拒、收款方式快照在改名与停用后保持冻结、缺少交易流水号被拒、重复交易流水号不阻断线下申请、企业微信审批或钱包入账,且不改变 `payment_transaction_id`、`payment_channel`、支付状态、钱包或 Outbox 入账消费者的对账判定;支付凭证与其他凭证分别留存并可在审批详情取得。 +- [x] 5.5 验证识别预填:图片凭证识别成功后返回预填值且未创建申请、人工更正值生效、非图片与识别失败不阻断人工填写、日志与审计不含凭证内容或识别原始结果。 +- [x] 5.6 验证字典引用保护:被代理充值引用的字典项不可删除、编码不可修改、仅可停用。 +- [ ] 5.7 执行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`./scripts/context-health.sh`、`openspec validate add-agent-self-recharge-payment-methods --strict` 与 `openspec doctor --json`;上述全局健康门禁必须一并通过,自动化测试按项目决策为 N/A。 +- [x] 5.8 仅在本 Change fixture 已清理、且维护者确认新增收款方式、交易流水号与凭证快照没有留存需求时验证迁移 up/down/up;down 可以删除新增列,但不得影响既有充值记录读取。存在仍需保留的真实新增列数据时不得将 down 视为正常可执行场景,MUST NOT 重置整个 `junhong_cmp_test`。 diff --git a/pkg/constants/agent_recharge.go b/pkg/constants/agent_recharge.go index cfcf699..25d1772 100644 --- a/pkg/constants/agent_recharge.go +++ b/pkg/constants/agent_recharge.go @@ -23,6 +23,34 @@ const ( AgentRechargeSourceAgentOnline = "agent_online" ) +// 代理线下预存款审批字段边界。 +const ( + // AgentRechargeExternalTransactionNoMaxLength 表示线下充值交易流水号最大长度。 + AgentRechargeExternalTransactionNoMaxLength = 128 + // AgentRechargePaymentVoucherMaxCount 表示一次线下充值允许的最多支付凭证数量。 + AgentRechargePaymentVoucherMaxCount = 5 + // AgentRechargeOtherVoucherMaxCount 表示一次线下充值允许的最多其他凭证数量。 + AgentRechargeOtherVoucherMaxCount = 5 + // AgentRechargeVoucherKeyMaxLength 表示单个凭证对象键最大长度。 + AgentRechargeVoucherKeyMaxLength = 500 + // AgentRechargeVoucherMaxBytes 表示单张付款凭证图片允许的最大字节数。 + AgentRechargeVoucherMaxBytes = 10 << 20 +) + +// GetAgentSelfRechargeAllowedMethodsName 返回代理在线自充允许范围的中文名称。 +func GetAgentSelfRechargeAllowedMethodsName(value string) string { + switch value { + case AgentSelfRechargeAllowedWechatOnly: + return "仅微信" + case AgentSelfRechargeAllowedAlipayOnly: + return "仅支付宝" + case AgentSelfRechargeAllowedBoth: + return "同时支持微信和支付宝" + default: + return "未知允许范围" + } +} + // GetAgentRechargeSource 根据支付方式返回稳定的充值来源及中文名称。 func GetAgentRechargeSource(paymentMethod string) (string, string) { if paymentMethod == RechargeMethodWechat || paymentMethod == RechargeMethodAlipay { diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index 8086045..a094c3e 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -31,6 +31,14 @@ const ( ApprovalFieldSubmitterID = "submitter_id" // ApprovalFieldSubmitterName 表示真实业务提交人账号名称业务字段。 ApprovalFieldSubmitterName = "submitter_name" + // ApprovalFieldOfflinePaymentMethod 表示线下代充值收款方式名称快照业务字段。 + ApprovalFieldOfflinePaymentMethod = "offline_payment_method" + // ApprovalFieldOfflinePaymentMethodCode 表示线下代充值收款方式稳定编码业务字段。 + ApprovalFieldOfflinePaymentMethodCode = "offline_payment_method_code" + // ApprovalFieldExternalTransactionNo 表示线下代充值交易流水号业务字段。 + ApprovalFieldExternalTransactionNo = "external_transaction_no" + // ApprovalFieldOtherVoucherKey 表示线下代充值其他凭证对象存储 Key 列表业务字段。 + ApprovalFieldOtherVoucherKey = "other_voucher_key" // ApprovalFieldRefundNo 表示退款单号业务字段。 ApprovalFieldRefundNo = "refund_no" // ApprovalFieldOrderID 表示退款关联订单 ID 业务字段。 diff --git a/pkg/constants/system_config.go b/pkg/constants/system_config.go index a0db02e..a21dc00 100644 --- a/pkg/constants/system_config.go +++ b/pkg/constants/system_config.go @@ -24,6 +24,14 @@ const ( SystemConfigPaymentAllowedCard = "c2b.payment.card_allowed_methods" // SystemConfigPaymentAllowedDevice 定义设备资产允许的支付方式集合。 SystemConfigPaymentAllowedDevice = "c2b.payment.device_allowed_methods" + // SystemConfigAgentSelfRechargeAllowedMethods 定义代理在线自充允许范围配置 Key。 + SystemConfigAgentSelfRechargeAllowedMethods = "agent.self_recharge.allowed_methods" + // AgentSelfRechargeAllowedWechatOnly 表示代理在线自充仅允许微信。 + AgentSelfRechargeAllowedWechatOnly = "wechat_only" + // AgentSelfRechargeAllowedAlipayOnly 表示代理在线自充仅允许支付宝。 + AgentSelfRechargeAllowedAlipayOnly = "alipay_only" + // AgentSelfRechargeAllowedBoth 表示代理在线自充同时允许微信和支付宝。 + AgentSelfRechargeAllowedBoth = "both" // SystemConfigCarrierCallbackCTCCRealnameEnabled 控制电信实名回调是否执行业务处理。 SystemConfigCarrierCallbackCTCCRealnameEnabled = "carrier_callback.ctcc_realname.enabled" // SystemConfigCarrierCallbackCMCCRealnameEnabled 控制移动实名回调是否执行业务处理。 diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 1b6c572..5c29df7 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -490,8 +490,8 @@ var errorMessages = map[int]string{ CodeAuditDataArchived: "数据已归档,第一阶段不支持在线查询", CodeEmployeeCollectionPaymentMethodNotFound: "线下收款方式不存在", CodeEmployeeCollectionPaymentMethodCodeExists: "线下收款方式编码已存在", - CodeEmployeeCollectionPaymentMethodReferenced: "线下收款方式已被核销申请引用,只能停用", - CodeEmployeeCollectionPaymentMethodDisabled: "线下收款方式已停用,不能用于新的核销申请", + CodeEmployeeCollectionPaymentMethodReferenced: "线下收款方式已被核销申请或代理充值申请引用,只能停用", + CodeEmployeeCollectionPaymentMethodDisabled: "线下收款方式已停用,不能用于新的业务申请", CodeEmployeeCollectionBillNotFound: "员工代收款账单不存在", CodeEmployeeCollectionBillNotSettleable: "账单当前状态不允许核销", CodeEmployeeCollectionAllocationExceeded: "分摊金额超过账单可核销余额",