From ba0855d9eb9ae204edb9920a6feda7dc814e0219 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 14 Sep 2026 11:55:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=80=80=E6=AC=BE):=20AUG26-006=20?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E6=96=B9=E5=BC=8F=E9=80=89=E6=8B=A9=E4=B8=8E?= =?UTF-8?q?=E5=8E=9F=E8=B7=AF=E9=80=80=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款: - 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款), 提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。 - 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试 记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。 新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。 - 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个 入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。 - 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠 失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。 订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。 - 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、 支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。 渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多 提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。 - 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、 微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动 重提以避免重复退款。 - 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。 迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供, 未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。 --- cmd/worker/main.go | 38 + docs/integrations/fuiou/README.md | 27 +- .../entry-capability-requirement-matrix.json | 84 +- .../context-reset/requirement-evidence.json | 1385 ++++++++++------- .../application/refundapproval/creation.go | 345 +++- .../application/refundapproval/resolve.go | 101 ++ internal/application/refundchannel/audit.go | 32 + internal/application/refundchannel/event.go | 75 + internal/application/refundchannel/number.go | 71 + internal/application/refundchannel/recover.go | 193 +++ internal/application/refundchannel/service.go | 701 +++++++++ internal/bootstrap/services.go | 11 + internal/exporter/refund_scene.go | 81 +- internal/infrastructure/audit/approval.go | 99 +- .../infrastructure/audit/refund_channel.go | 105 ++ internal/infrastructure/audit/registry.go | 25 +- .../infrastructure/payment/refund_adapter.go | 462 ++++++ .../payment/refund_channel_recovery_task.go | 39 + internal/model/dto/refund_dto.go | 119 +- internal/model/dto/wechat_config_dto.go | 22 +- internal/model/refund.go | 68 +- internal/model/wechat_config.go | 3 + internal/query/audit/finance.go | 58 +- internal/routes/payment_merchant.go | 3 +- internal/routes/refund.go | 22 +- internal/service/refund/approval_decision.go | 126 +- internal/service/refund/attempt_query.go | 87 ++ internal/service/refund/method.go | 273 ++++ internal/service/refund/payment_merchant.go | 77 +- internal/service/refund/service.go | 296 ++-- internal/service/wechat_config/service.go | 54 +- ...refund_attempt_and_channel_refund.down.sql | 63 + ...d_refund_attempt_and_channel_refund.up.sql | 173 ++ ...wechat_v2_client_cert_credentials.down.sql | 6 + ...d_wechat_v2_client_cert_credentials.up.sql | 13 + .../design.md | 245 ++- .../proposal.md | 16 +- .../specs/merchant-payment-routing/spec.md | 92 ++ .../specs/order-refund-exchange/spec.md | 178 ++- .../tasks.md | 19 +- .../agent-distribution-withdrawal/spec.md | 16 + pkg/alipay/refund.go | 185 +++ pkg/constants/audit.go | 20 + pkg/constants/constants.go | 3 +- pkg/constants/iot.go | 14 +- pkg/constants/refund.go | 110 ++ pkg/fuiou/refund.go | 206 +++ pkg/wechat/config.go | 10 +- pkg/wechat/payment_v2.go | 5 + pkg/wechat/payment_v2_refund.go | 310 ++++ pkg/wechat/refund.go | 215 +++ 51 files changed, 5995 insertions(+), 986 deletions(-) create mode 100644 internal/application/refundapproval/resolve.go create mode 100644 internal/application/refundchannel/audit.go create mode 100644 internal/application/refundchannel/event.go create mode 100644 internal/application/refundchannel/number.go create mode 100644 internal/application/refundchannel/recover.go create mode 100644 internal/application/refundchannel/service.go create mode 100644 internal/infrastructure/audit/refund_channel.go create mode 100644 internal/infrastructure/payment/refund_adapter.go create mode 100644 internal/infrastructure/payment/refund_channel_recovery_task.go create mode 100644 internal/service/refund/attempt_query.go create mode 100644 internal/service/refund/method.go create mode 100644 migrations/000218_add_refund_attempt_and_channel_refund.down.sql create mode 100644 migrations/000218_add_refund_attempt_and_channel_refund.up.sql create mode 100644 migrations/000219_add_wechat_v2_client_cert_credentials.down.sql create mode 100644 migrations/000219_add_wechat_v2_client_cert_credentials.up.sql create mode 100644 openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md create mode 100644 pkg/alipay/refund.go create mode 100644 pkg/constants/refund.go create mode 100644 pkg/fuiou/refund.go create mode 100644 pkg/wechat/payment_v2_refund.go create mode 100644 pkg/wechat/refund.go diff --git a/cmd/worker/main.go b/cmd/worker/main.go index d3d0e95..2983bf3 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -22,6 +22,7 @@ import ( employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" + refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/gateway" @@ -85,6 +86,9 @@ type workerRuntime struct { pollingIotCardStore *postgres.IotCardStore pollingBase *task.PollingBase lifecycleSvc *polling.PollingLifecycleService + // refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它, + // 使恢复确认的成功与直接调用确认的成功走同一回写路径。 + refundChannelService *refundchannelApp.Service } func main() { @@ -156,6 +160,7 @@ func runWorker(cfg *config.Config) { taskHandler.RegisterHandlers() registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger) registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger) + registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) @@ -385,6 +390,17 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con refundService.SetEmployeeCollectionRefundOffset( employeecollectionApp.NewRefundOffsetService(auditWriter), ) + refundChannelService := refundchannelApp.NewService( + runtime.db, + merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient), + paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger), + auditWriter, + ).SetLogger(appLogger).SetCompletionNotifier(refundService) + refundService.SetChannelRefundService(refundChannelService) + runtime.refundChannelService = refundChannelService + if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil { + appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err)) + } if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil { appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err)) } @@ -483,6 +499,18 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery)) } +// registerRefundChannelRecoveryTask 注册渠道原路退款结果恢复任务。 +// 该任务只查询渠道并回填结果,绝不重复发起资金动作。 +// 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。 +func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) { + if runtime == nil || runtime.refundChannelService == nil { + appLogger.Fatal("渠道原路退款用例未配置") + } + handler := paymentInfra.NewRefundChannelRecoveryTaskHandler(runtime.refundChannelService) + mux.HandleFunc(constants.TaskTypeRefundChannelRecovery, handler.Handle) + appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery)) +} + // registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) @@ -747,6 +775,16 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna )); err != nil { return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( + constants.TaskTypeRefundChannelRecovery, + nil, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + asynq.Unique(10*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)), + )); err != nil { + return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err) + } if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( constants.TaskTypeOrderExpire, nil, diff --git a/docs/integrations/fuiou/README.md b/docs/integrations/fuiou/README.md index 584eacc..d9bfc5a 100644 --- a/docs/integrations/fuiou/README.md +++ b/docs/integrations/fuiou/README.md @@ -9,22 +9,37 @@ ## 当前实际使用范围 -系统当前使用微信预下单 `POST /wxPreCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。 +系统使用微信预下单 `POST /wxPreCreate`、主扫统一下单 `POST /preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT` 或 `ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。 -真实核验仍需隔离富友商户、明确接口权限、合法订单样本和允许的网络条件;在这些条件完成前,不得将查单、验签、状态映射或恢复结果作为上线证据。本 Change 不新增富友退款能力。 +## 原路退款 + +退款申请 `POST /commonRefund`,必填 `version`、`ins_cd`、`mchnt_cd`、`term_id`、`mchnt_order_no`、`random_str`、`sign`、`order_type`、`refund_order_no`、`total_amt`、`refund_amt`;选填 `operator_id`、`reserved_fy_term_id`、`reserved_origi_dt`、`reserved_addn_inf`、`reserved_refund_desc`。响应 `result_code=000000` 表示渠道受理成功,此时取 `refund_id`(富友退款流水号)、`transaction_id`、`reserved_refund_amt`(退款金额,分)、`reserved_fy_settle_dt`(清算日期)。`reserved` 开头字段随报文发出但不参与签名。 + +退款查询 `POST /refundQuery`,入参为 `refund_order_no`;响应 `trans_stat` 取值为 `SUCCESS`(退款成功)或 `PAYERROR`(退款失败),未返回该字段表示仍在办理中。 + +全局约束:`mchnt_order_no` 与 `refund_order_no` 均为全局永久唯一,重复提交会被直接拒绝;商户退款单号格式为「机构码(4 位) + 日期(yyyyMMdd) + 随机段(8–18 位字母数字)」,本系统按该规则生成,三渠道共用同一生成器;接口支持全额退款与多次部分退款。 + +原交易日期决定可退时限:不传 `reserved_origi_dt` 仅支持 30 天内的原交易,传了可退 360 天内的原交易。本系统始终回传原支付成功时间,因此按 360 天判定可退性,超出该时限的申请在选择退款方式阶段即禁用原路。 + +退款查询接口只支持查询 3 日内的退款交易。超出该窗口且结果仍未知时,系统保留原路退款处理中状态、标记审批异常并转人工核对,绝不重复发起退款。 ## 配置、认证与传输 运行配置包含 API 地址、机构号、商户号、终端号、RSA 私钥、公钥及通知地址。请求先生成 XML,再转换为 GBK,并对请求参数做双重 URL 编码;请求和响应使用 RSA 签名/验签。除 `reserved` 外的请求字段即使为空也参与 XML 与签名。 -关键请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。 +关键支付请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。 ## 幂等、失败与重试 -`mchnt_order_no` 是渠道业务幂等键;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。 +`mchnt_order_no` 是渠道业务幂等键,退款侧对应 `refund_order_no`;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。 + +退款调用以冻结在审批尝试记录上的渠道退款请求号作为幂等标识:同一次尝试的渠道重试复用同一请求号,重提会生成新请求号。结果未知时只由查询恢复回填,不得重复发起资金动作。 ## 安全与验证 -RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道。 +RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/scan.go`、`pkg/fuiou/refund.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`、`internal/infrastructure/payment/fuiou_scan.go`、`internal/infrastructure/payment/refund_adapter.go`。 + +本文按官方契约记录退款接口,**未做真实渠道实测**:未实测只作记录,不作为阻塞、未完成任务或上线前置;真实渠道可退款性由维护者后续手工验证。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符、重复通知、退款受理与退款查询;本次不调用真实渠道。 + +端点、编码、签名字段、成功码、退款字段或通知语义变化时更新本文。 -端点、编码、签名字段、成功码或通知语义变化时更新本文。 diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index e9190c0..2eb89c5 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -1252,7 +1252,8 @@ "capability": "agent-funds-commission", "requirements": [ "agent-funds-commission::代理钱包与提现状态门禁", - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "agent-distribution-withdrawal::提现冻结与企业微信终审" ], "classification": "behavior" }, @@ -1357,7 +1358,8 @@ "entry": "GET /api/c/v1/asset/info", "capability": "personal-customer", "requirements": [ - "personal-customer::资产归属查询" + "personal-customer::资产归属查询", + "personal-customer::资产信息展示当前可用或已用完主套餐" ], "classification": "behavior" }, @@ -1864,7 +1866,8 @@ "entry": "POST /api/admin/commission/withdrawal-requests/{id}/approve", "capability": "agent-funds-commission", "requirements": [ - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "agent-distribution-withdrawal::提现本地人工终审边界" ], "classification": "behavior" }, @@ -1873,7 +1876,8 @@ "entry": "POST /api/admin/commission/withdrawal-requests/{id}/reject", "capability": "agent-funds-commission", "requirements": [ - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "agent-distribution-withdrawal::提现本地人工终审边界" ], "classification": "behavior" }, @@ -2434,7 +2438,9 @@ "capability": "agent-funds-commission", "requirements": [ "agent-funds-commission::代理钱包与提现状态门禁", - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "agent-distribution-withdrawal::提现资料资格", + "agent-distribution-withdrawal::提现冻结与企业微信终审" ], "classification": "behavior" }, @@ -3350,7 +3356,8 @@ "agent-funds-commission::退款后处理可补偿", "order-commission-delivery::佣金计算重复投递幂等", "order-commission-delivery::已支付订单佣金计算可靠投递", - "order-commission-delivery::待计算订单可补偿" + "order-commission-delivery::待计算订单可补偿", + "agent-distribution-withdrawal::待审提现与佣金回溯的释放接缝" ], "classification": "route_index_or_infrastructure" }, @@ -3917,5 +3924,70 @@ "agent-funds-commission::付款凭证识别仅作交易流水号预填" ], "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.TaskTypeRefundChannelRecovery", + "capability": "order-refund-exchange", + "requirements": [ + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::代理退款查询按所属店铺隔离" + ], + "classification": "route_index_or_infrastructure" + }, + { + "entry_type": "http", + "entry": "POST /api/c/v1/agent-distribution-registrations", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::分销码与待审批代理注册" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/shops/{shop_id}/withdrawal-qualifications", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::提现资料资格" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/shops/{shop_id}/withdrawal-qualifications", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::提现资料资格" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/withdrawal-qualifications/{id}/void", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::提现资料资格" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::提现冻结与企业微信终审", + "agent-distribution-withdrawal::待审提现与佣金回溯的释放接缝" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::提现冻结与企业微信终审" + ], + "classification": "behavior" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index d778fea..f050cc2 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -1609,36 +1609,36 @@ "exit_status": 0 } }, -{ - "capability": "agent-funds-commission", - "requirement": "代理在线充值可用支付方式按允许范围与可用商户池判定", - "spec": "openspec/specs/agent-funds-commission/spec.md", - "entries": [ - "GET /api/admin/agent-recharges/payment-methods", - "GET /api/admin/agent-self-recharge-payment-methods", - "POST /api/admin/agent-recharges" - ], - "handler_consumer_job": [ - "internal/handler/admin/agent_recharge.go:AgentRechargeHandler.PaymentMethods" - ], - "application_service_query": [ - "internal/application/agentrecharge/online_creation.go:OnlineCreationService.AvailablePaymentMethods", - "internal/application/agentrecharge/online_payment_policy.go:OnlinePaymentMethodPolicy.AllowedMethods" - ], - "domain_state_amount": [ - "internal/model/agent_wallet.go:AgentRechargeRecord.PaymentMethod/PaymentChannel" - ], - "store_migration_config": [ - "internal/application/agentrecharge/online_creation.go:adapter.Available" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[(\"internal/application/agentrecharge/online_creation.go\", \"allowed, err := s.policy.AllowedMethods(ctx)\"), (\"internal/application/agentrecharge/online_creation.go\", \"adapter.Available(config)\"), (\"internal/application/agentrecharge/online_payment_policy.go\", \"func (p *OnlinePaymentMethodPolicy) AllowedMethods(\"), (\"internal/application/agentrecharge/online_payment_policy.go\", \"func (p *OnlinePaymentMethodPolicy) IsAllowed(\"), (\"internal/handler/admin/agent_recharge.go\", \"func (h *AgentRechargeHandler) SelfRechargePaymentMethods(\")]; negative=[]; missing=[f\"{p}:{t}\" for p,t in checks if t not in Path(p).read_text()]; present=[f\"{p}:{t}\" for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {\"missing\": missing, \"present\": present}; print(\"OK: agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定\")'", - "literal_output": [ - "OK: agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定" + { + "capability": "agent-funds-commission", + "requirement": "代理在线充值可用支付方式按允许范围与可用商户池判定", + "spec": "openspec/specs/agent-funds-commission/spec.md", + "entries": [ + "GET /api/admin/agent-recharges/payment-methods", + "GET /api/admin/agent-self-recharge-payment-methods", + "POST /api/admin/agent-recharges" ], - "exit_status": 0 - } -}, + "handler_consumer_job": [ + "internal/handler/admin/agent_recharge.go:AgentRechargeHandler.PaymentMethods" + ], + "application_service_query": [ + "internal/application/agentrecharge/online_creation.go:OnlineCreationService.AvailablePaymentMethods", + "internal/application/agentrecharge/online_payment_policy.go:OnlinePaymentMethodPolicy.AllowedMethods" + ], + "domain_state_amount": [ + "internal/model/agent_wallet.go:AgentRechargeRecord.PaymentMethod/PaymentChannel" + ], + "store_migration_config": [ + "internal/application/agentrecharge/online_creation.go:adapter.Available" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[(\"internal/application/agentrecharge/online_creation.go\", \"allowed, err := s.policy.AllowedMethods(ctx)\"), (\"internal/application/agentrecharge/online_creation.go\", \"adapter.Available(config)\"), (\"internal/application/agentrecharge/online_payment_policy.go\", \"func (p *OnlinePaymentMethodPolicy) AllowedMethods(\"), (\"internal/application/agentrecharge/online_payment_policy.go\", \"func (p *OnlinePaymentMethodPolicy) IsAllowed(\"), (\"internal/handler/admin/agent_recharge.go\", \"func (h *AgentRechargeHandler) SelfRechargePaymentMethods(\")]; negative=[]; missing=[f\"{p}:{t}\" for p,t in checks if t not in Path(p).read_text()]; present=[f\"{p}:{t}\" for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {\"missing\": missing, \"present\": present}; print(\"OK: agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定\")'", + "literal_output": [ + "OK: agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定" + ], + "exit_status": 0 + } + }, { "capability": "agent-open-api", "requirement": "代理开放接口查询不触发可靠卡观测", @@ -1675,270 +1675,270 @@ "exit_status": 0 } }, -{ - "capability": "external-integration", - "requirement": "富友主扫统一下单与订单查询", - "spec": "openspec/specs/external-integration/spec.md", - "entries": [ - "POST /api/admin/agent-recharges" - ], - "handler_consumer_job": [ - "internal/infrastructure/payment/fuiou_scan.go:FuiouScanAdapter.CreatePaymentURL/Query" - ], - "application_service_query": [ - "pkg/fuiou/scan.go:Client.PreCreate/CommonQuery" - ], - "domain_state_amount": [ - "pkg/fuiou/types.go:CommonQueryResponse.TransStat" - ], - "store_migration_config": [ - "internal/infrastructure/integrationlog" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/infrastructure/payment/fuiou_scan.go'\"'\"', '\"'\"'client.PreCreate('\"'\"'), ('\"'\"'internal/infrastructure/payment/fuiou_scan.go'\"'\"', '\"'\"'client.CommonQuery('\"'\"'), ('\"'\"'pkg/fuiou/scan.go'\"'\"', '\"'\"'func (c *Client) PreCreate'\"'\"'), ('\"'\"'pkg/fuiou/scan.go'\"'\"', '\"'\"'func (c *Client) CommonQuery'\"'\"'), ('\"'\"'pkg/fuiou/types.go'\"'\"', '\"'\"'xml:\"trans_stat\"'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::富友主扫统一下单与订单查询'\"'\"')'", - "literal_output": [ - "OK: external-integration::富友主扫统一下单与订单查询" + { + "capability": "external-integration", + "requirement": "富友主扫统一下单与订单查询", + "spec": "openspec/specs/external-integration/spec.md", + "entries": [ + "POST /api/admin/agent-recharges" ], - "exit_status": 0 - } -}, -{ - "capability": "external-integration", - "requirement": "高频轮询外部交互日志保留边界", - "spec": "openspec/specs/external-integration/spec.md", - "entries": [ - "constants.TaskTypePollingCarddata", - "constants.TaskTypePollingCardStatus", - "constants.TaskTypePollingRealname" - ], - "handler_consumer_job": [ - "internal/task/polling_integration_log.go:newGatewayAttempt/recordGatewayAttempt" - ], - "application_service_query": [ - "internal/application/cardobservation/traffic.go:ApplyTrafficObservation" - ], - "domain_state_amount": [ - "internal/infrastructure/integrationlog.Repository:Attempt.StateChanged" - ], - "store_migration_config": [ - "internal/query/retention/retention.go:Info.OnlineFrom/ArchivedBefore" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/task/polling_integration_log.go'\"'\"', '\"'\"'成功无变化的轮询不落库'\"'\"'), ('\"'\"'internal/task/polling_carddata_handler.go'\"'\"', '\"'\"'recordGatewayAttempt'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'ArchivedBefore'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::高频轮询外部交互日志保留边界'\"'\"')'", - "literal_output": [ - "OK: external-integration::高频轮询外部交互日志保留边界" + "handler_consumer_job": [ + "internal/infrastructure/payment/fuiou_scan.go:FuiouScanAdapter.CreatePaymentURL/Query" ], - "exit_status": 0 - } -}, -{ - "capability": "external-integration", - "requirement": "外部交互日志归档与留存受控执行", - "spec": "openspec/specs/external-integration/spec.md", - "entries": [ - "constants.TaskTypeIntegrationDailyArchive", - "constants.TaskTypeAuditDailyRetention" - ], - "handler_consumer_job": [ - "internal/task/integration_archive.go:IntegrationArchiveHandler.HandleDaily", - "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" - ], - "application_service_query": [ - "internal/application/auditarchive/integration.go:ArchivePreviousIntegrationDay", - "internal/application/auditarchive/retention.go:RetainPendingDays" - ], - "domain_state_amount": [ - "internal/application/auditarchive/retention.go:RetentionBlockedError" - ], - "store_migration_config": [ - "pkg/constants/audit_archive.go:IntegrationArchiveSource" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/task/integration_archive.go'\"'\"', '\"'\"'func (h *IntegrationArchiveHandler) HandleDaily'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'cleanupEnabled'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'RetentionBlockedError'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::外部交互日志归档与留存受控执行'\"'\"')'", - "literal_output": [ - "OK: external-integration::外部交互日志归档与留存受控执行" + "application_service_query": [ + "pkg/fuiou/scan.go:Client.PreCreate/CommonQuery" ], - "exit_status": 0 - } -}, -{ - "capability": "external-integration", - "requirement": "外部交互日志逐日物理留存", - "spec": "openspec/specs/external-integration/spec.md", - "entries": [ - "constants.TaskTypeIntegrationDailyArchive", - "constants.TaskTypeAuditDailyRetention" - ], - "handler_consumer_job": [ - "internal/task/integration_archive.go:IntegrationArchiveHandler.HandleDaily", - "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" - ], - "application_service_query": [ - "internal/application/auditarchive/integration.go:ArchivePreviousIntegrationDay", - "internal/application/auditarchive/retention.go:RetainPendingDays" - ], - "domain_state_amount": [ - "internal/application/auditarchive/retention.go:RetentionResult.IntegrationCount" - ], - "store_migration_config": [ - "internal/query/retention/retention.go:SourceIntegration/tb_integration_log" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/auditarchive/integration.go'\"'\"', '\"'\"'ArchivePreviousIntegrationDay'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'retainIntegrationDate'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'tb_integration_log'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::外部交互日志逐日物理留存'\"'\"')'", - "literal_output": [ - "OK: external-integration::外部交互日志逐日物理留存" + "domain_state_amount": [ + "pkg/fuiou/types.go:CommonQueryResponse.TransStat" ], - "exit_status": 0 - } -}, -{ - "capability": "operations-audit", - "requirement": "轮询审计事实保留边界", - "spec": "openspec/specs/operations-audit/spec.md", - "entries": [ - "constants.TaskTypePollingCarddata", - "constants.TaskTypePollingCardStatus", - "constants.TaskTypePollingRealname" - ], - "handler_consumer_job": [ - "internal/task/polling_carddata_handler.go:Handle", - "internal/task/polling_cardstatus_handler.go:Handle", - "internal/task/polling_realname_handler.go:Handle" - ], - "application_service_query": [ - "internal/application/cardobservation/apply.go:ApplyCardObservation", - "internal/application/cardobservation/network.go:ApplyNetworkObservation", - "internal/application/cardobservation/traffic.go:ApplyTrafficObservation" - ], - "domain_state_amount": [ - "internal/application/cardobservation:workerObservationAudited" - ], - "store_migration_config": [ - "internal/query/audit/events.go:EventPage.Retention" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/cardobservation/network.go'\"'\"', '\"'\"'workerObservationAudited(ctx) && stateChanged'\"'\"'), ('\"'\"'internal/application/cardobservation/traffic.go'\"'\"', '\"'\"'workerObservationAudited(ctx) && stateChanged'\"'\"'), ('\"'\"'internal/query/audit/events.go'\"'\"', '\"'\"'在线查询留存边界'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::轮询审计事实保留边界'\"'\"')'", - "literal_output": [ - "OK: operations-audit::轮询审计事实保留边界" + "store_migration_config": [ + "internal/infrastructure/integrationlog" ], - "exit_status": 0 - } -}, -{ - "capability": "operations-audit", - "requirement": "审计归档与日留存受控执行", - "spec": "openspec/specs/operations-audit/spec.md", - "entries": [ - "constants.TaskTypeAuditDailyArchive", - "constants.TaskTypeAuditDailyRetention" - ], - "handler_consumer_job": [ - "cmd/worker/main.go:registerAsynqScheduleTasks", - "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" - ], - "application_service_query": [ - "internal/application/auditarchive/service.go:ArchivePreviousDay", - "internal/application/auditarchive/retention.go:RetainPendingDays" - ], - "domain_state_amount": [ - "internal/application/auditarchive/retention.go:RetentionBlockedError" - ], - "store_migration_config": [ - "pkg/constants/audit_archive.go:TaskTypeAuditDailyArchive/TaskTypeAuditDailyRetention" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'constants.TaskTypeAuditDailyArchive'\"'\"'), ('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'constants.TaskTypeAuditDailyRetention'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'cleanupEnabled'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计归档与日留存受控执行'\"'\"')'", - "literal_output": [ - "OK: operations-audit::审计归档与日留存受控执行" + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/infrastructure/payment/fuiou_scan.go'\"'\"', '\"'\"'client.PreCreate('\"'\"'), ('\"'\"'internal/infrastructure/payment/fuiou_scan.go'\"'\"', '\"'\"'client.CommonQuery('\"'\"'), ('\"'\"'pkg/fuiou/scan.go'\"'\"', '\"'\"'func (c *Client) PreCreate'\"'\"'), ('\"'\"'pkg/fuiou/scan.go'\"'\"', '\"'\"'func (c *Client) CommonQuery'\"'\"'), ('\"'\"'pkg/fuiou/types.go'\"'\"', '\"'\"'xml:\"trans_stat\"'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::富友主扫统一下单与订单查询'\"'\"')'", + "literal_output": [ + "OK: external-integration::富友主扫统一下单与订单查询" + ], + "exit_status": 0 + } + }, + { + "capability": "external-integration", + "requirement": "高频轮询外部交互日志保留边界", + "spec": "openspec/specs/external-integration/spec.md", + "entries": [ + "constants.TaskTypePollingCarddata", + "constants.TaskTypePollingCardStatus", + "constants.TaskTypePollingRealname" ], - "exit_status": 0 - } -}, -{ - "capability": "operations-audit", - "requirement": "审计在线数据逐日物理留存", - "spec": "openspec/specs/operations-audit/spec.md", - "entries": [ - "constants.TaskTypeAuditDailyRetention" - ], - "handler_consumer_job": [ - "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" - ], - "application_service_query": [ - "internal/application/auditarchive/retention.go:RetainPendingDays/retainDate" - ], - "domain_state_amount": [ - "internal/application/auditarchive/retention.go:RetentionResult.EventCount/ResourceCount" - ], - "store_migration_config": [ - "internal/query/retention/retention.go:tb_audit_event/LogArchiveRun" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'RetainPendingDays'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'retainAuditDate'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'tb_audit_event'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计在线数据逐日物理留存'\"'\"')'", - "literal_output": [ - "OK: operations-audit::审计在线数据逐日物理留存" + "handler_consumer_job": [ + "internal/task/polling_integration_log.go:newGatewayAttempt/recordGatewayAttempt" ], - "exit_status": 0 - } -}, -{ - "capability": "operations-audit", - "requirement": "日留存独立运行日志", - "spec": "openspec/specs/operations-audit/spec.md", - "entries": [ - "constants.TaskTypeAuditDailyRetention" - ], - "handler_consumer_job": [ - "cmd/worker/main.go:NewRetentionLogger", - "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" - ], - "application_service_query": [ - "internal/application/auditarchive/retention.go:RetainPendingDays" - ], - "domain_state_amount": [ - "internal/application/auditarchive/retention.go:RetentionResult.Duration" - ], - "store_migration_config": [ - "cmd/worker/main.go:cfg.Logging.RetentionLog" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'logger.NewRetentionLogger'\"'\"'), ('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'cfg.Logging.RetentionLog'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'日志日留存处理完成'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::日留存独立运行日志'\"'\"')'", - "literal_output": [ - "OK: operations-audit::日留存独立运行日志" + "application_service_query": [ + "internal/application/cardobservation/traffic.go:ApplyTrafficObservation" ], - "exit_status": 0 - } -}, -{ - "capability": "operations-audit", - "requirement": "审计调查留存边界连续", - "spec": "openspec/specs/operations-audit/spec.md", - "entries": [ - "GET /api/admin/audit/events", - "GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline" - ], - "handler_consumer_job": [ - "internal/handler/admin/audit.go", - "internal/query/audit/timeline.go:LinkTimeline" - ], - "application_service_query": [ - "internal/query/audit/events.go:List", - "internal/query/audit/timeline.go:LinkTimeline" - ], - "domain_state_amount": [ - "internal/query/retention/retention.go:NormalizeRange" - ], - "store_migration_config": [ - "internal/query/retention/retention.go:sourceBoundary" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/query/audit/timeline.go'\"'\"', '\"'\"'Audit与Integration共同在线留存边界'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'func NormalizeRange'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'for _, row := range rows'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计调查留存边界连续'\"'\"')'", - "literal_output": [ - "OK: operations-audit::审计调查留存边界连续" + "domain_state_amount": [ + "internal/infrastructure/integrationlog.Repository:Attempt.StateChanged" ], - "exit_status": 0 - } -}, + "store_migration_config": [ + "internal/query/retention/retention.go:Info.OnlineFrom/ArchivedBefore" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/task/polling_integration_log.go'\"'\"', '\"'\"'成功无变化的轮询不落库'\"'\"'), ('\"'\"'internal/task/polling_carddata_handler.go'\"'\"', '\"'\"'recordGatewayAttempt'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'ArchivedBefore'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::高频轮询外部交互日志保留边界'\"'\"')'", + "literal_output": [ + "OK: external-integration::高频轮询外部交互日志保留边界" + ], + "exit_status": 0 + } + }, + { + "capability": "external-integration", + "requirement": "外部交互日志归档与留存受控执行", + "spec": "openspec/specs/external-integration/spec.md", + "entries": [ + "constants.TaskTypeIntegrationDailyArchive", + "constants.TaskTypeAuditDailyRetention" + ], + "handler_consumer_job": [ + "internal/task/integration_archive.go:IntegrationArchiveHandler.HandleDaily", + "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" + ], + "application_service_query": [ + "internal/application/auditarchive/integration.go:ArchivePreviousIntegrationDay", + "internal/application/auditarchive/retention.go:RetainPendingDays" + ], + "domain_state_amount": [ + "internal/application/auditarchive/retention.go:RetentionBlockedError" + ], + "store_migration_config": [ + "pkg/constants/audit_archive.go:IntegrationArchiveSource" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/task/integration_archive.go'\"'\"', '\"'\"'func (h *IntegrationArchiveHandler) HandleDaily'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'cleanupEnabled'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'RetentionBlockedError'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::外部交互日志归档与留存受控执行'\"'\"')'", + "literal_output": [ + "OK: external-integration::外部交互日志归档与留存受控执行" + ], + "exit_status": 0 + } + }, + { + "capability": "external-integration", + "requirement": "外部交互日志逐日物理留存", + "spec": "openspec/specs/external-integration/spec.md", + "entries": [ + "constants.TaskTypeIntegrationDailyArchive", + "constants.TaskTypeAuditDailyRetention" + ], + "handler_consumer_job": [ + "internal/task/integration_archive.go:IntegrationArchiveHandler.HandleDaily", + "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" + ], + "application_service_query": [ + "internal/application/auditarchive/integration.go:ArchivePreviousIntegrationDay", + "internal/application/auditarchive/retention.go:RetainPendingDays" + ], + "domain_state_amount": [ + "internal/application/auditarchive/retention.go:RetentionResult.IntegrationCount" + ], + "store_migration_config": [ + "internal/query/retention/retention.go:SourceIntegration/tb_integration_log" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/auditarchive/integration.go'\"'\"', '\"'\"'ArchivePreviousIntegrationDay'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'retainIntegrationDate'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'tb_integration_log'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: external-integration::外部交互日志逐日物理留存'\"'\"')'", + "literal_output": [ + "OK: external-integration::外部交互日志逐日物理留存" + ], + "exit_status": 0 + } + }, + { + "capability": "operations-audit", + "requirement": "轮询审计事实保留边界", + "spec": "openspec/specs/operations-audit/spec.md", + "entries": [ + "constants.TaskTypePollingCarddata", + "constants.TaskTypePollingCardStatus", + "constants.TaskTypePollingRealname" + ], + "handler_consumer_job": [ + "internal/task/polling_carddata_handler.go:Handle", + "internal/task/polling_cardstatus_handler.go:Handle", + "internal/task/polling_realname_handler.go:Handle" + ], + "application_service_query": [ + "internal/application/cardobservation/apply.go:ApplyCardObservation", + "internal/application/cardobservation/network.go:ApplyNetworkObservation", + "internal/application/cardobservation/traffic.go:ApplyTrafficObservation" + ], + "domain_state_amount": [ + "internal/application/cardobservation:workerObservationAudited" + ], + "store_migration_config": [ + "internal/query/audit/events.go:EventPage.Retention" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/cardobservation/network.go'\"'\"', '\"'\"'workerObservationAudited(ctx) && stateChanged'\"'\"'), ('\"'\"'internal/application/cardobservation/traffic.go'\"'\"', '\"'\"'workerObservationAudited(ctx) && stateChanged'\"'\"'), ('\"'\"'internal/query/audit/events.go'\"'\"', '\"'\"'在线查询留存边界'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::轮询审计事实保留边界'\"'\"')'", + "literal_output": [ + "OK: operations-audit::轮询审计事实保留边界" + ], + "exit_status": 0 + } + }, + { + "capability": "operations-audit", + "requirement": "审计归档与日留存受控执行", + "spec": "openspec/specs/operations-audit/spec.md", + "entries": [ + "constants.TaskTypeAuditDailyArchive", + "constants.TaskTypeAuditDailyRetention" + ], + "handler_consumer_job": [ + "cmd/worker/main.go:registerAsynqScheduleTasks", + "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" + ], + "application_service_query": [ + "internal/application/auditarchive/service.go:ArchivePreviousDay", + "internal/application/auditarchive/retention.go:RetainPendingDays" + ], + "domain_state_amount": [ + "internal/application/auditarchive/retention.go:RetentionBlockedError" + ], + "store_migration_config": [ + "pkg/constants/audit_archive.go:TaskTypeAuditDailyArchive/TaskTypeAuditDailyRetention" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'constants.TaskTypeAuditDailyArchive'\"'\"'), ('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'constants.TaskTypeAuditDailyRetention'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'cleanupEnabled'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计归档与日留存受控执行'\"'\"')'", + "literal_output": [ + "OK: operations-audit::审计归档与日留存受控执行" + ], + "exit_status": 0 + } + }, + { + "capability": "operations-audit", + "requirement": "审计在线数据逐日物理留存", + "spec": "openspec/specs/operations-audit/spec.md", + "entries": [ + "constants.TaskTypeAuditDailyRetention" + ], + "handler_consumer_job": [ + "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" + ], + "application_service_query": [ + "internal/application/auditarchive/retention.go:RetainPendingDays/retainDate" + ], + "domain_state_amount": [ + "internal/application/auditarchive/retention.go:RetentionResult.EventCount/ResourceCount" + ], + "store_migration_config": [ + "internal/query/retention/retention.go:tb_audit_event/LogArchiveRun" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'RetainPendingDays'\"'\"'), ('\"'\"'internal/application/auditarchive/retention.go'\"'\"', '\"'\"'retainAuditDate'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'tb_audit_event'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计在线数据逐日物理留存'\"'\"')'", + "literal_output": [ + "OK: operations-audit::审计在线数据逐日物理留存" + ], + "exit_status": 0 + } + }, + { + "capability": "operations-audit", + "requirement": "日留存独立运行日志", + "spec": "openspec/specs/operations-audit/spec.md", + "entries": [ + "constants.TaskTypeAuditDailyRetention" + ], + "handler_consumer_job": [ + "cmd/worker/main.go:NewRetentionLogger", + "internal/task/audit_monthly_retention.go:AuditRetentionHandler.Handle" + ], + "application_service_query": [ + "internal/application/auditarchive/retention.go:RetainPendingDays" + ], + "domain_state_amount": [ + "internal/application/auditarchive/retention.go:RetentionResult.Duration" + ], + "store_migration_config": [ + "cmd/worker/main.go:cfg.Logging.RetentionLog" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'logger.NewRetentionLogger'\"'\"'), ('\"'\"'cmd/worker/main.go'\"'\"', '\"'\"'cfg.Logging.RetentionLog'\"'\"'), ('\"'\"'internal/task/audit_monthly_retention.go'\"'\"', '\"'\"'日志日留存处理完成'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::日留存独立运行日志'\"'\"')'", + "literal_output": [ + "OK: operations-audit::日留存独立运行日志" + ], + "exit_status": 0 + } + }, + { + "capability": "operations-audit", + "requirement": "审计调查留存边界连续", + "spec": "openspec/specs/operations-audit/spec.md", + "entries": [ + "GET /api/admin/audit/events", + "GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline" + ], + "handler_consumer_job": [ + "internal/handler/admin/audit.go", + "internal/query/audit/timeline.go:LinkTimeline" + ], + "application_service_query": [ + "internal/query/audit/events.go:List", + "internal/query/audit/timeline.go:LinkTimeline" + ], + "domain_state_amount": [ + "internal/query/retention/retention.go:NormalizeRange" + ], + "store_migration_config": [ + "internal/query/retention/retention.go:sourceBoundary" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/query/audit/timeline.go'\"'\"', '\"'\"'Audit与Integration共同在线留存边界'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'func NormalizeRange'\"'\"'), ('\"'\"'internal/query/retention/retention.go'\"'\"', '\"'\"'for _, row := range rows'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: operations-audit::审计调查留存边界连续'\"'\"')'", + "literal_output": [ + "OK: operations-audit::审计调查留存边界连续" + ], + "exit_status": 0 + } + }, { "capability": "package-lifecycle", "requirement": "资产套餐层级投影", @@ -2090,61 +2090,61 @@ "exit_status": 0 } }, -{ - "capability": "package-queue-activation", - "requirement": "当前主套餐过期后自动激活下一个", - "spec": "openspec/specs/package-queue-activation/spec.md", - "entries": [ - "constants.TaskTypePackageQueueActivation" - ], - "handler_consumer_job": [ - "internal/polling/package_activation_handler.go:PackageActivationHandler.Handle", - "pkg/queue/handler.go:HandlePackageQueueActivation" - ], - "application_service_query": [ - "internal/polling/package_activation_handler.go:activateNextPackage" - ], - "domain_state_amount": [ - "internal/model/package.go:PackageUsage.Priority/MasterUsageID" - ], - "store_migration_config": [ - "internal/polling/package_activation_handler.go:tb_package_usage" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'expires_at <= ?'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'enqueueActivationTask'\"'\"'), ('\"'\"'pkg/queue/handler.go'\"'\"', '\"'\"'TaskTypePackageQueueActivation'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: package-queue-activation::当前主套餐过期后自动激活下一个'\"'\"')'", - "literal_output": [ - "OK: package-queue-activation::当前主套餐过期后自动激活下一个" + { + "capability": "package-queue-activation", + "requirement": "当前主套餐过期后自动激活下一个", + "spec": "openspec/specs/package-queue-activation/spec.md", + "entries": [ + "constants.TaskTypePackageQueueActivation" ], - "exit_status": 0 - } -}, -{ - "capability": "package-queue-activation", - "requirement": "孤儿待生效套餐必须公平恢复", - "spec": "openspec/specs/package-queue-activation/spec.md", - "entries": [ - "constants.TaskTypePackageQueueActivation" - ], - "handler_consumer_job": [ - "internal/polling/package_activation_handler.go:findAndActivateOrphanPackages" - ], - "application_service_query": [ - "internal/polling/package_activation_handler.go:enqueueActivationTask" - ], - "domain_state_amount": [ - "internal/polling/package_activation_handler.go:ROW_NUMBER/priority/created_at/id" - ], - "store_migration_config": [ - "internal/polling/package_activation_handler.go:tb_package_usage" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'func (h *PackageActivationHandler) findAndActivateOrphanPackages'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'ROW_NUMBER() OVER'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: package-queue-activation::孤儿待生效套餐必须公平恢复'\"'\"')'", - "literal_output": [ - "OK: package-queue-activation::孤儿待生效套餐必须公平恢复" + "handler_consumer_job": [ + "internal/polling/package_activation_handler.go:PackageActivationHandler.Handle", + "pkg/queue/handler.go:HandlePackageQueueActivation" ], - "exit_status": 0 - } -}, + "application_service_query": [ + "internal/polling/package_activation_handler.go:activateNextPackage" + ], + "domain_state_amount": [ + "internal/model/package.go:PackageUsage.Priority/MasterUsageID" + ], + "store_migration_config": [ + "internal/polling/package_activation_handler.go:tb_package_usage" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'expires_at <= ?'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'enqueueActivationTask'\"'\"'), ('\"'\"'pkg/queue/handler.go'\"'\"', '\"'\"'TaskTypePackageQueueActivation'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: package-queue-activation::当前主套餐过期后自动激活下一个'\"'\"')'", + "literal_output": [ + "OK: package-queue-activation::当前主套餐过期后自动激活下一个" + ], + "exit_status": 0 + } + }, + { + "capability": "package-queue-activation", + "requirement": "孤儿待生效套餐必须公平恢复", + "spec": "openspec/specs/package-queue-activation/spec.md", + "entries": [ + "constants.TaskTypePackageQueueActivation" + ], + "handler_consumer_job": [ + "internal/polling/package_activation_handler.go:findAndActivateOrphanPackages" + ], + "application_service_query": [ + "internal/polling/package_activation_handler.go:enqueueActivationTask" + ], + "domain_state_amount": [ + "internal/polling/package_activation_handler.go:ROW_NUMBER/priority/created_at/id" + ], + "store_migration_config": [ + "internal/polling/package_activation_handler.go:tb_package_usage" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'func (h *PackageActivationHandler) findAndActivateOrphanPackages'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'ROW_NUMBER() OVER'\"'\"'), ('\"'\"'internal/polling/package_activation_handler.go'\"'\"', '\"'\"'ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: package-queue-activation::孤儿待生效套餐必须公平恢复'\"'\"')'", + "literal_output": [ + "OK: package-queue-activation::孤儿待生效套餐必须公平恢复" + ], + "exit_status": 0 + } + }, { "capability": "package-queue-activation", "requirement": "Asynq 激活结果必须准确且可重试", @@ -2311,143 +2311,143 @@ "exit_status": 0 } }, -{ - "capability": "qicheng-migration-package-lifecycle-overrides", - "requirement": "迁移配置支持逐卡套餐生命周期覆盖", - "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", - "entries": [ - "scripts/migration/migrate_runtime.py" - ], - "handler_consumer_job": [ - "scripts/migration/migrate_runtime.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/mapping_loader.py:Mapping.lookup_package_lifecycle_override", - "scripts/migration/lib/sql_builder.py:_apply_package_lifecycle_override" - ], - "domain_state_amount": [ - "scripts/migration/lib/mapping_loader.py:PackageLifecycleOverride" - ], - "store_migration_config": [ - "scripts/migration/config/mapping.yaml", - "scripts/migration/lib/legacy_query.py:tbl_card_life" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/mapping_loader.py'\"'\"', '\"'\"'package_lifecycle_overrides'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'_apply_package_lifecycle_override'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖'\"'\"')'", - "literal_output": [ - "OK: qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖" + { + "capability": "qicheng-migration-package-lifecycle-overrides", + "requirement": "迁移配置支持逐卡套餐生命周期覆盖", + "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", + "entries": [ + "scripts/migration/migrate_runtime.py" ], - "exit_status": 0 - } -}, -{ - "capability": "qicheng-migration-package-lifecycle-overrides", - "requirement": "覆盖必须完整且可验证", - "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", - "entries": [ - "scripts/migration/migrate_runtime.py" - ], - "handler_consumer_job": [ - "scripts/migration/migrate_runtime.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/sql_builder.py:_apply_package_lifecycle_override" - ], - "domain_state_amount": [ - "scripts/migration/lib/sql_builder.py:selected != migratable" - ], - "store_migration_config": [ - "scripts/migration/lib/mapping_loader.py:_load_package_lifecycle_overrides" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'套餐生命周期覆盖必须完整裁决原本可迁移记录'\"'\"'), ('\"'\"'scripts/migration/lib/mapping_loader.py'\"'\"', '\"'\"'生命周期 ID 不可重复或跨状态重复'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证'\"'\"')'", - "literal_output": [ - "OK: qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证" + "handler_consumer_job": [ + "scripts/migration/migrate_runtime.py:main" ], - "exit_status": 0 - } -}, -{ - "capability": "qicheng-migration-package-lifecycle-overrides", - "requirement": "未覆盖资产维持严格冲突阻断", - "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", - "entries": [ - "scripts/migration/migrate_runtime.py" - ], - "handler_consumer_job": [ - "scripts/migration/migrate_runtime.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/sql_builder.py:_resolve_runtime_packages" - ], - "domain_state_amount": [ - "scripts/migration/lib/sql_builder.py:multiple_active_packages/blocked" - ], - "store_migration_config": [ - "scripts/migration/lib/legacy_query.py:tbl_card_life" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'multiple_active_packages'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'同一资产存在多个当前生效正式套餐,无法唯一裁决'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断'\"'\"')'", - "literal_output": [ - "OK: qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断" + "application_service_query": [ + "scripts/migration/lib/mapping_loader.py:Mapping.lookup_package_lifecycle_override", + "scripts/migration/lib/sql_builder.py:_apply_package_lifecycle_override" ], - "exit_status": 0 - } -}, -{ - "capability": "qicheng-migration-package-usage", - "requirement": "迁移套餐使用记录保存计时条款快照", - "spec": "openspec/specs/qicheng-migration-package-usage/spec.md", - "entries": [ - "scripts/migration/migrate_runtime.py" - ], - "handler_consumer_job": [ - "scripts/migration/migrate_runtime.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/sql_builder.py:_write_package_usages" - ], - "domain_state_amount": [ - "scripts/migration/lib/sql_builder.py:expiry_base_snapshot/calendar_type_snapshot/duration snapshots" - ], - "store_migration_config": [ - "migrations/000163_add_package_usage_terms_snapshots.up.sql" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'expiry_base_snapshot, calendar_type_snapshot, duration_months_snapshot, duration_days_snapshot'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'INSERT INTO tb_package_usage'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照'\"'\"')'", - "literal_output": [ - "OK: qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照" + "domain_state_amount": [ + "scripts/migration/lib/mapping_loader.py:PackageLifecycleOverride" ], - "exit_status": 0 - } -}, -{ - "capability": "qicheng-migration-package-usage", - "requirement": "迁移套餐使用 SQL 满足快照完整性约束", - "spec": "openspec/specs/qicheng-migration-package-usage/spec.md", - "entries": [ - "scripts/migration/migrate_runtime.py" - ], - "handler_consumer_job": [ - "scripts/migration/migrate_runtime.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/sql_builder.py:_write_package_usages" - ], - "domain_state_amount": [ - "migrations/000163_add_package_usage_terms_snapshots.up.sql:validate_package_usage_terms_snapshot" - ], - "store_migration_config": [ - "migrations/000163_add_package_usage_terms_snapshots.up.sql:trg_validate_package_usage_terms_snapshot" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'migrations/000163_add_package_usage_terms_snapshots.up.sql'\"'\"', '\"'\"'CREATE TRIGGER trg_validate_package_usage_terms_snapshot'\"'\"'), ('\"'\"'migrations/000163_add_package_usage_terms_snapshots.up.sql'\"'\"', '\"'\"'套餐使用记录计时快照不完整或无效'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束'\"'\"')'", - "literal_output": [ - "OK: qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束" + "store_migration_config": [ + "scripts/migration/config/mapping.yaml", + "scripts/migration/lib/legacy_query.py:tbl_card_life" ], - "exit_status": 0 - } -}, + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/mapping_loader.py'\"'\"', '\"'\"'package_lifecycle_overrides'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'_apply_package_lifecycle_override'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖'\"'\"')'", + "literal_output": [ + "OK: qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖" + ], + "exit_status": 0 + } + }, + { + "capability": "qicheng-migration-package-lifecycle-overrides", + "requirement": "覆盖必须完整且可验证", + "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", + "entries": [ + "scripts/migration/migrate_runtime.py" + ], + "handler_consumer_job": [ + "scripts/migration/migrate_runtime.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/sql_builder.py:_apply_package_lifecycle_override" + ], + "domain_state_amount": [ + "scripts/migration/lib/sql_builder.py:selected != migratable" + ], + "store_migration_config": [ + "scripts/migration/lib/mapping_loader.py:_load_package_lifecycle_overrides" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'套餐生命周期覆盖必须完整裁决原本可迁移记录'\"'\"'), ('\"'\"'scripts/migration/lib/mapping_loader.py'\"'\"', '\"'\"'生命周期 ID 不可重复或跨状态重复'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证'\"'\"')'", + "literal_output": [ + "OK: qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证" + ], + "exit_status": 0 + } + }, + { + "capability": "qicheng-migration-package-lifecycle-overrides", + "requirement": "未覆盖资产维持严格冲突阻断", + "spec": "openspec/specs/qicheng-migration-package-lifecycle-overrides/spec.md", + "entries": [ + "scripts/migration/migrate_runtime.py" + ], + "handler_consumer_job": [ + "scripts/migration/migrate_runtime.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/sql_builder.py:_resolve_runtime_packages" + ], + "domain_state_amount": [ + "scripts/migration/lib/sql_builder.py:multiple_active_packages/blocked" + ], + "store_migration_config": [ + "scripts/migration/lib/legacy_query.py:tbl_card_life" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'multiple_active_packages'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'同一资产存在多个当前生效正式套餐,无法唯一裁决'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断'\"'\"')'", + "literal_output": [ + "OK: qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断" + ], + "exit_status": 0 + } + }, + { + "capability": "qicheng-migration-package-usage", + "requirement": "迁移套餐使用记录保存计时条款快照", + "spec": "openspec/specs/qicheng-migration-package-usage/spec.md", + "entries": [ + "scripts/migration/migrate_runtime.py" + ], + "handler_consumer_job": [ + "scripts/migration/migrate_runtime.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/sql_builder.py:_write_package_usages" + ], + "domain_state_amount": [ + "scripts/migration/lib/sql_builder.py:expiry_base_snapshot/calendar_type_snapshot/duration snapshots" + ], + "store_migration_config": [ + "migrations/000163_add_package_usage_terms_snapshots.up.sql" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'expiry_base_snapshot, calendar_type_snapshot, duration_months_snapshot, duration_days_snapshot'\"'\"'), ('\"'\"'scripts/migration/lib/sql_builder.py'\"'\"', '\"'\"'INSERT INTO tb_package_usage'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照'\"'\"')'", + "literal_output": [ + "OK: qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照" + ], + "exit_status": 0 + } + }, + { + "capability": "qicheng-migration-package-usage", + "requirement": "迁移套餐使用 SQL 满足快照完整性约束", + "spec": "openspec/specs/qicheng-migration-package-usage/spec.md", + "entries": [ + "scripts/migration/migrate_runtime.py" + ], + "handler_consumer_job": [ + "scripts/migration/migrate_runtime.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/sql_builder.py:_write_package_usages" + ], + "domain_state_amount": [ + "migrations/000163_add_package_usage_terms_snapshots.up.sql:validate_package_usage_terms_snapshot" + ], + "store_migration_config": [ + "migrations/000163_add_package_usage_terms_snapshots.up.sql:trg_validate_package_usage_terms_snapshot" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'migrations/000163_add_package_usage_terms_snapshots.up.sql'\"'\"', '\"'\"'CREATE TRIGGER trg_validate_package_usage_terms_snapshot'\"'\"'), ('\"'\"'migrations/000163_add_package_usage_terms_snapshots.up.sql'\"'\"', '\"'\"'套餐使用记录计时快照不完整或无效'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束'\"'\"')'", + "literal_output": [ + "OK: qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束" + ], + "exit_status": 0 + } + }, { "capability": "refund-approval", "requirement": "零金额退款审批通过", @@ -2538,141 +2538,141 @@ "exit_status": 0 } }, -{ - "capability": "shop-bulk-import", - "requirement": "导入输入必须在生成 SQL 前完成全量预检", - "spec": "openspec/specs/shop-bulk-import/spec.md", - "entries": [ - "scripts/migration/import_shops.py" - ], - "handler_consumer_job": [ - "scripts/migration/import_shops.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/shop_import.py:load_config/load_plan" - ], - "domain_state_amount": [ - "scripts/migration/lib/shop_import.py:ShopImportPlan.errors" - ], - "store_migration_config": [ - "scripts/migration/config/shop_bulk_import.yaml" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'plan = load_plan(csv_path, config)'\"'\"'), ('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'if plan.errors:'\"'\"'), ('\"'\"'scripts/migration/lib/shop_import.py'\"'\"', '\"'\"'def load_plan'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检'\"'\"')'", - "literal_output": [ - "OK: shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检" + { + "capability": "shop-bulk-import", + "requirement": "导入输入必须在生成 SQL 前完成全量预检", + "spec": "openspec/specs/shop-bulk-import/spec.md", + "entries": [ + "scripts/migration/import_shops.py" ], - "exit_status": 0 - } -}, -{ - "capability": "shop-bulk-import", - "requirement": "生成的 SQL 必须在写入前校验目标库状态", - "spec": "openspec/specs/shop-bulk-import/spec.md", - "entries": [ - "scripts/migration/import_shops.py" - ], - "handler_consumer_job": [ - "scripts/migration/import_shops.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/shop_sql.py:build_shop_sql/_preflight_sql" - ], - "domain_state_amount": [ - "scripts/migration/lib/shop_sql.py:DO $shop_import_guard$" - ], - "store_migration_config": [ - "scripts/migration/lib/shop_sql.py:tb_role/tb_account/tb_shop guards" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'所有目标库守卫必须在业务写入前完成。'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'DO $shop_import_guard$'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态'\"'\"')'", - "literal_output": [ - "OK: shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态" + "handler_consumer_job": [ + "scripts/migration/import_shops.py:main" ], - "exit_status": 0 - } -}, -{ - "capability": "shop-bulk-import", - "requirement": "批量导入 SQL 必须创建完整的店铺初始事实", - "spec": "openspec/specs/shop-bulk-import/spec.md", - "entries": [ - "scripts/migration/import_shops.py" - ], - "handler_consumer_job": [ - "scripts/migration/import_shops.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/shop_sql.py:_row_sql/_audit_sql" - ], - "domain_state_amount": [ - "scripts/migration/lib/shop_import.py:initial_password_hash" - ], - "store_migration_config": [ - "scripts/migration/lib/shop_sql.py:tb_shop/tb_account/tb_account_role/tb_shop_role/tb_agent_wallet/tb_audit_event" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_shop'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_account_role'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_agent_wallet'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_audit_event'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实'\"'\"')'", - "literal_output": [ - "OK: shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实" + "application_service_query": [ + "scripts/migration/lib/shop_import.py:load_config/load_plan" ], - "exit_status": 0 - } -}, -{ - "capability": "shop-bulk-import", - "requirement": "导入执行必须由维护者审核后以单事务手工确认", - "spec": "openspec/specs/shop-bulk-import/spec.md", - "entries": [ - "scripts/migration/import_shops.py" - ], - "handler_consumer_job": [ - "scripts/migration/import_shops.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/shop_sql.py:build_shop_sql" - ], - "domain_state_amount": [ - "scripts/migration/lib/shop_sql.py:review before psql -1" - ], - "store_migration_config": [ - "scripts/migration/lib/shop_sql.py:psql -1" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'执行前必须审核结果清单与摘要。'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'psql -1 -v ON_ERROR_STOP=1 -f <此文件>'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认'\"'\"')'", - "literal_output": [ - "OK: shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认" + "domain_state_amount": [ + "scripts/migration/lib/shop_import.py:ShopImportPlan.errors" ], - "exit_status": 0 - } -}, -{ - "capability": "shop-bulk-import", - "requirement": "导入结果必须可审核和重跑定位", - "spec": "openspec/specs/shop-bulk-import/spec.md", - "entries": [ - "scripts/migration/import_shops.py" - ], - "handler_consumer_job": [ - "scripts/migration/import_shops.py:main" - ], - "application_service_query": [ - "scripts/migration/lib/shop_import.py:write_errors/write_results/write_summary" - ], - "domain_state_amount": [ - "scripts/migration/lib/shop_import.py:batch_id/ShopImportResult" - ], - "store_migration_config": [ - "scripts/migration/config/shop_bulk_import.yaml" - ], - "verification": { - "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'write_results(result_path, plan.results)'\"'\"'), ('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'write_summary(summary_path, plan, sql_path)'\"'\"'), ('\"'\"'scripts/migration/lib/shop_import.py'\"'\"', '\"'\"'batch_id={plan.config.batch_id}'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入结果必须可审核和重跑定位'\"'\"')'", - "literal_output": [ - "OK: shop-bulk-import::导入结果必须可审核和重跑定位" + "store_migration_config": [ + "scripts/migration/config/shop_bulk_import.yaml" ], - "exit_status": 0 - } -}, + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'plan = load_plan(csv_path, config)'\"'\"'), ('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'if plan.errors:'\"'\"'), ('\"'\"'scripts/migration/lib/shop_import.py'\"'\"', '\"'\"'def load_plan'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检'\"'\"')'", + "literal_output": [ + "OK: shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检" + ], + "exit_status": 0 + } + }, + { + "capability": "shop-bulk-import", + "requirement": "生成的 SQL 必须在写入前校验目标库状态", + "spec": "openspec/specs/shop-bulk-import/spec.md", + "entries": [ + "scripts/migration/import_shops.py" + ], + "handler_consumer_job": [ + "scripts/migration/import_shops.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/shop_sql.py:build_shop_sql/_preflight_sql" + ], + "domain_state_amount": [ + "scripts/migration/lib/shop_sql.py:DO $shop_import_guard$" + ], + "store_migration_config": [ + "scripts/migration/lib/shop_sql.py:tb_role/tb_account/tb_shop guards" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'所有目标库守卫必须在业务写入前完成。'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'DO $shop_import_guard$'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态'\"'\"')'", + "literal_output": [ + "OK: shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态" + ], + "exit_status": 0 + } + }, + { + "capability": "shop-bulk-import", + "requirement": "批量导入 SQL 必须创建完整的店铺初始事实", + "spec": "openspec/specs/shop-bulk-import/spec.md", + "entries": [ + "scripts/migration/import_shops.py" + ], + "handler_consumer_job": [ + "scripts/migration/import_shops.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/shop_sql.py:_row_sql/_audit_sql" + ], + "domain_state_amount": [ + "scripts/migration/lib/shop_import.py:initial_password_hash" + ], + "store_migration_config": [ + "scripts/migration/lib/shop_sql.py:tb_shop/tb_account/tb_account_role/tb_shop_role/tb_agent_wallet/tb_audit_event" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_shop'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_account_role'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_agent_wallet'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'INSERT INTO tb_audit_event'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实'\"'\"')'", + "literal_output": [ + "OK: shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实" + ], + "exit_status": 0 + } + }, + { + "capability": "shop-bulk-import", + "requirement": "导入执行必须由维护者审核后以单事务手工确认", + "spec": "openspec/specs/shop-bulk-import/spec.md", + "entries": [ + "scripts/migration/import_shops.py" + ], + "handler_consumer_job": [ + "scripts/migration/import_shops.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/shop_sql.py:build_shop_sql" + ], + "domain_state_amount": [ + "scripts/migration/lib/shop_sql.py:review before psql -1" + ], + "store_migration_config": [ + "scripts/migration/lib/shop_sql.py:psql -1" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'执行前必须审核结果清单与摘要。'\"'\"'), ('\"'\"'scripts/migration/lib/shop_sql.py'\"'\"', '\"'\"'psql -1 -v ON_ERROR_STOP=1 -f <此文件>'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认'\"'\"')'", + "literal_output": [ + "OK: shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认" + ], + "exit_status": 0 + } + }, + { + "capability": "shop-bulk-import", + "requirement": "导入结果必须可审核和重跑定位", + "spec": "openspec/specs/shop-bulk-import/spec.md", + "entries": [ + "scripts/migration/import_shops.py" + ], + "handler_consumer_job": [ + "scripts/migration/import_shops.py:main" + ], + "application_service_query": [ + "scripts/migration/lib/shop_import.py:write_errors/write_results/write_summary" + ], + "domain_state_amount": [ + "scripts/migration/lib/shop_import.py:batch_id/ShopImportResult" + ], + "store_migration_config": [ + "scripts/migration/config/shop_bulk_import.yaml" + ], + "verification": { + "command": "python3 -c 'from pathlib import Path; checks=[('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'write_results(result_path, plan.results)'\"'\"'), ('\"'\"'scripts/migration/import_shops.py'\"'\"', '\"'\"'write_summary(summary_path, plan, sql_path)'\"'\"'), ('\"'\"'scripts/migration/lib/shop_import.py'\"'\"', '\"'\"'batch_id={plan.config.batch_id}'\"'\"')]; negative=[]; missing=[f'\"'\"'{p}:{t}'\"'\"' for p,t in checks if t not in Path(p).read_text()]; present=[f'\"'\"'{p}:{t}'\"'\"' for p,t in negative if t in Path(p).read_text()]; assert not missing and not present, {'\"'\"'missing'\"'\"': missing, '\"'\"'present'\"'\"': present}; print('\"'\"'OK: shop-bulk-import::导入结果必须可审核和重跑定位'\"'\"')'", + "literal_output": [ + "OK: shop-bulk-import::导入结果必须可审核和重跑定位" + ], + "exit_status": 0 + } + }, { "capability": "employee-collection-bill", "requirement": "员工代收款账单来源、金额与建账判据", @@ -2976,5 +2976,192 @@ ], "exit_status": 0 } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "分销码与待审批代理注册", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "/api/c/v1/agent-distribution-registrations" + ], + "handler_consumer_job": [ + "internal/routes/agent_distribution_public.go", + "internal/handler/app/agent_distribution.go", + "internal/application/distributionwithdrawal/registration_approval.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/distributionwithdrawal/registration.go" + ], + "domain_state_amount": [ + "internal/domain/distribution/distribution.go", + "pkg/constants/approval.go" + ], + "store_migration_config": [ + "migrations/000214_add_agent_distribution_registration.up.sql", + "migrations/000217_backfill_shop_distribution_code.up.sql" + ], + "verification": { + "command": "grep -rn \"分销码不可用\" internal/", + "literal_output": [ + "internal/application/distributionwithdrawal/registration.go:64:return nil, errors.New(errors.CodeInvalidParam, \"分销码不可用\")" + ], + "exit_status": 0 + } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "提现资料资格", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "/api/admin/shops/{shop_id}/withdrawal-qualifications", + "/api/admin/withdrawal-qualifications/{id}/void" + ], + "handler_consumer_job": [ + "internal/routes/shop.go", + "internal/handler/admin/withdrawal_qualification.go", + "internal/application/distributionwithdrawal/qualification_approval.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/distributionwithdrawal/qualification.go" + ], + "domain_state_amount": [ + "pkg/constants/withdrawal_qualification.go", + "pkg/constants/approval.go" + ], + "store_migration_config": [ + "migrations/000215_add_withdrawal_qualification_and_attempt.up.sql" + ], + "verification": { + "command": "grep -rn \"uk_withdrawal_qualification_valid\" migrations/", + "literal_output": [ + "migrations/000215_add_withdrawal_qualification_and_attempt.up.sql:50:CREATE UNIQUE INDEX uk_withdrawal_qualification_valid" + ], + "exit_status": 0 + } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "提现冻结与企业微信终审", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "/api/admin/shops/{shop_id}/withdrawal-requests" + ], + "handler_consumer_job": [ + "internal/routes/shop.go", + "internal/handler/admin/shop_commission.go", + "internal/application/distributionwithdrawal/withdrawal.go", + "internal/application/distributionwithdrawal/withdrawal_approval.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/distributionwithdrawal/withdrawal.go" + ], + "domain_state_amount": [ + "internal/domain/wallet/wallet.go", + "pkg/constants/commission_withdrawal.go", + "pkg/constants/approval.go" + ], + "store_migration_config": [ + "internal/store/postgres/commission_withdrawal_request_store.go", + "migrations/000215_add_withdrawal_qualification_and_attempt.up.sql" + ], + "verification": { + "command": "sed -n \"120,130p\" internal/application/distributionwithdrawal/withdrawal_approval.go", + "literal_output": [ + "internal/application/distributionwithdrawal/withdrawal_approval.go:125:\"status\": constants.WithdrawalStatusApproved," + ], + "exit_status": 0 + } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "提现本地人工终审边界", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "/api/admin/commission/withdrawal-requests/{id}/approve", + "/api/admin/commission/withdrawal-requests/{id}/reject" + ], + "handler_consumer_job": [ + "internal/routes/commission.go", + "internal/handler/admin/commission_withdrawal.go" + ], + "application_service_query": [ + "internal/service/commission_withdrawal/service.go" + ], + "domain_state_amount": [ + "pkg/constants/commission_withdrawal.go" + ], + "store_migration_config": [ + "internal/store/postgres/commission_withdrawal_request_store.go", + "migrations/000215_add_withdrawal_qualification_and_attempt.up.sql" + ], + "verification": { + "command": "grep -rn \"提现申请已接入企业微信终审\" internal/", + "literal_output": [ + "internal/service/commission_withdrawal/service.go:165:businessErr := errors.New(errors.CodeConflict, \"提现申请已接入企业微信终审,不支持本地人工处理\")", + "internal/service/commission_withdrawal/service.go:292:businessErr := errors.New(errors.CodeConflict, \"提现申请已接入企业微信终审,不支持本地人工处理\")" + ], + "exit_status": 0 + } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "待审提现与佣金回溯的释放接缝", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [], + "handler_consumer_job": [ + "internal/application/distributionwithdrawal/withdrawal.go", + "internal/service/refund/service.go" + ], + "application_service_query": [ + "internal/application/distributionwithdrawal/withdrawal.go" + ], + "domain_state_amount": [ + "internal/model/financial.go", + "pkg/constants/commission_withdrawal.go" + ], + "store_migration_config": [ + "internal/store/postgres/withdrawal_attempt_release.go", + "migrations/000215_add_withdrawal_qualification_and_attempt.up.sql" + ], + "verification": { + "command": "grep -n \"released_at IS NULL\" internal/store/postgres/withdrawal_attempt_release.go", + "literal_output": [ + "internal/store/postgres/withdrawal_attempt_release.go:41:.Where(\"request_id IN ? AND released_at IS NULL\", requestIDs).", + "internal/store/postgres/withdrawal_attempt_release.go:50:Where(\"id = ? AND released_at IS NULL\", attempt.ID)." + ], + "exit_status": 0 + } + }, + { + "capability": "personal-customer", + "requirement": "资产信息展示当前可用或已用完主套餐", + "spec": "openspec/specs/personal-customer/spec.md", + "entries": [ + "/api/c/v1/asset/info" + ], + "handler_consumer_job": [ + "internal/routes/personal.go", + "internal/handler/app/client_asset.go" + ], + "application_service_query": [ + "internal/service/asset/service.go" + ], + "domain_state_amount": [ + "pkg/constants/constants.go" + ], + "store_migration_config": [ + "internal/store/postgres/package_usage_store.go", + "internal/model/package.go" + ], + "verification": { + "command": "grep -n \"PackageUsageStatusDepleted\" internal/store/postgres/package_usage_store.go", + "literal_output": [ + "internal/store/postgres/package_usage_store.go:66:.Where(\"status IN (?, ?)\", constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted)." + ], + "exit_status": 0 + } } ] diff --git a/internal/application/refundapproval/creation.go b/internal/application/refundapproval/creation.go index 9f239c6..11da24d 100644 --- a/internal/application/refundapproval/creation.go +++ b/internal/application/refundapproval/creation.go @@ -4,9 +4,12 @@ package refundapproval import ( "context" "fmt" + "strconv" "strings" + "time" "github.com/bytedance/sonic" + "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -21,6 +24,8 @@ type CreateCommand struct { Refund *model.RefundRequest Order *model.Order SubmitterAccountID uint + // Attempt 是本次提交或重提新增的不可变审批尝试记录,其主键同时作为通用审批业务标识。 + Attempt *model.RefundRequestAttempt } // ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。 @@ -29,6 +34,12 @@ type ApplicationAudit struct { Order *model.Order Approval *model.ApprovalInstance Submitter *model.Account + // Attempt 非空时表示本次写入新增了一条审批尝试记录。 + Attempt *model.RefundRequestAttempt + // Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定, + // 使同一次重提的审计事件在该尝试上保持幂等。 + Action string + EventID string } // AuditWriter 接收退款申请事务内审计事实。 @@ -39,11 +50,16 @@ type AuditWriter interface { // CreateResult 返回原子保存后的退款申请和初始审批状态。 type CreateResult struct { Refund *model.RefundRequest + Attempt *model.RefundRequestAttempt SubmitterName string ApprovalStatus int } -// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。 +// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。 +// +// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识, +// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示, +// 其既有 approval_instance_id 语义与唯一约束保持不变。 type CreationService struct { db *gorm.DB approval approvalapp.Port @@ -55,8 +71,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite return &CreationService{db: db, approval: approval, audit: audit} } -// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。 // TriggerHistorical 为历史待审批退款补发一次企业微信审批。 +// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。 func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") @@ -91,12 +107,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) if err != nil { return nil, err } - submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account) - if err != nil { - return nil, err - } - var approvalStatus int + var result *CreateResult err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current model.RefundRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil { @@ -113,47 +125,61 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) if err := tx.WithContext(ctx).First(¤tOrder, current.OrderID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") } + + attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder) + if err != nil { + return err + } + attempt.SubmittedByAccountID = current.Creator + + submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account) + if err != nil { + return err + } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, - BusinessID: current.ID, SubmitterAccountID: current.Creator, + BusinessID: attempt.ID, SubmitterAccountID: current.Creator, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: current.RefundNo, }) if err != nil { return err } - result := tx.WithContext(ctx).Model(&model.RefundRequest{}). - Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending). - Update("approval_instance_id", reference.InstanceID) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败") + if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil { + return err } - if result.RowsAffected != 1 { - return errors.New(errors.CodeConflict, "退款审批实例关联已变化") + if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil { + return err } + current.ApprovalInstanceID = &reference.InstanceID refund = current order = currentOrder - approvalStatus = reference.Status var instance model.ApprovalInstance if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") } - return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ - Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, - }) + if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ + Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, Attempt: attempt, + }); err != nil { + return err + } + result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status} + return nil }) if err != nil { return nil, err } - return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil + return result, nil } +// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。 func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } - if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 || + if command.Refund == nil || command.Order == nil || command.Attempt == nil || + command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 || command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" { return nil, errors.New(errors.CodeInvalidParam) } @@ -179,33 +205,34 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (* } var activeCount int64 if err := tx.WithContext(ctx).Model(&model.RefundRequest{}). - Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}). + Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()). Count(&activeCount).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败") } if activeCount > 0 { - return errors.New(errors.CodeConflict, "该订单已存在退款申请") + return errors.New(errors.CodeConflict, "该订单已存在活动退款申请") } if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败") } + command.Attempt.RefundID = command.Refund.ID + if err := tx.WithContext(ctx).Create(command.Attempt).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败") + } reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, - BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID, + BusinessID: command.Attempt.ID, SubmitterAccountID: command.SubmitterAccountID, SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: command.Refund.RefundNo, }) if err != nil { return err } - result := tx.WithContext(ctx).Model(&model.RefundRequest{}). - Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID). - Update("approval_instance_id", reference.InstanceID) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败") + if err := attachAttemptInstance(ctx, tx, command.Attempt, reference.InstanceID); err != nil { + return err } - if result.RowsAffected != 1 { - return errors.New(errors.CodeConflict, "退款审批实例关联已变化") + if err := updateRefundLatest(ctx, tx, command.Refund, command.Attempt, reference.InstanceID); err != nil { + return err } command.Refund.ApprovalInstanceID = &reference.InstanceID approvalStatus = reference.Status @@ -214,13 +241,165 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (* return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") } return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ - Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, + Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: command.Attempt, }) }) if err != nil { return nil, err } - return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil + return &CreateResult{Refund: command.Refund, Attempt: command.Attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil +} + +// ResubmitCommand 描述重提时的材料变更。 +// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收), +// Attempt 是本次新增的不可变审批尝试记录。 +type ResubmitCommand struct { + Refund *model.RefundRequest + Attempt *model.RefundRequestAttempt +} + +// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。 +// +// 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或 +// 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例, +// 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。 +func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) { + if s == nil || s.db == nil || s.approval == nil || s.audit == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") + } + if refundID == 0 || command.Refund == nil || command.Attempt == nil || command.Refund.Creator == 0 { + return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整") + } + account, err := s.loadSubmitter(ctx, command.Refund.Creator) + if err != nil { + return nil, err + } + + var created *CreateResult + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败") + } + var current model.RefundRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "退款申请不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") + } + if !isResubmittable(¤t) { + return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") + } + var order model.Order + if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") + } + + // 材料已在调用方校验,这里把新值并入当前事实后冻结快照。 + current.Method = command.Refund.Method + current.RequestedRefundAmount = command.Refund.RequestedRefundAmount + current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount + current.RefundReason = command.Refund.RefundReason + current.RefundVoucherKey = command.Refund.RefundVoucherKey + current.CustomerAccountInfo = command.Refund.CustomerAccountInfo + + attempt, err := buildAttempt(ctx, tx, ¤t, &order) + if err != nil { + return err + } + attempt.SubmittedByAccountID = current.Creator + + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator, + CorrelationID: current.RefundNo, + }) + if err != nil { + return err + } + submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account) + if err != nil { + return err + } + + // 同一事务内回写材料、回到待审批并创建新的审批实例。 + updates := map[string]any{ + "status": model.RefundStatusPending, + "method": current.Method, + "requested_refund_amount": current.RequestedRefundAmount, + "frozen_actual_received_amount": current.FrozenActualReceivedAmount, + "refund_reason": current.RefundReason, + "refund_voucher_key": current.RefundVoucherKey, + "customer_account_info": current.CustomerAccountInfo, + "failure_reason": "", + "failure_message": "", + "channel_refund_status": constants.RefundChannelStatusNone, + "reject_reason": "", + "processor_id": nil, + "processed_at": nil, + "updater": current.Creator, + "updated_at": time.Now().UTC(), + } + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()). + Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款申请状态已变化") + } + current.Status = model.RefundStatusPending + + if err := tx.WithContext(ctx).Create(attempt).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败") + } + reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ + Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, + BusinessID: attempt.ID, SubmitterAccountID: current.Creator, + SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, + CorrelationID: current.RefundNo, + }) + if err != nil { + return err + } + if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil { + return err + } + if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil { + return err + } + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") + } + if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{ + Refund: ¤t, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt, + Action: constants.AuditActionRefundResubmitted, + EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10), + }); err != nil { + return err + } + created = &CreateResult{Refund: ¤t, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status} + return nil + }) + if err != nil { + return nil, err + } + return created, nil +} + +// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。 +// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。 +func isResubmittable(refund *model.RefundRequest) bool { + if refund == nil || refund.AnomalyFlag != 0 { + return false + } + for _, status := range model.RefundResubmittableStatuses() { + if refund.Status == status { + return true + } + } + return false } func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) { @@ -234,6 +413,104 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m return &account, nil } +// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。 +// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。 +func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (*model.RefundRequestAttempt, error) { + attemptNo, err := nextAttemptNo(ctx, tx, refund.ID) + if err != nil { + return nil, err + } + snapshot, err := packageUsageSnapshot(ctx, tx, refund, order) + if err != nil { + return nil, err + } + return &model.RefundRequestAttempt{ + RefundID: refund.ID, + AttemptNo: attemptNo, + Method: refund.Method, + RefundAmount: refund.RequestedRefundAmount, + FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount, + RefundReason: refund.RefundReason, + CustomerAccountInfo: refund.CustomerAccountInfo, + CustomerVoucherKeys: refund.RefundVoucherKey, + PackageUsageSnapshot: snapshot, + SubmittedByAccountID: refund.Creator, + }, nil +} + +// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。 +func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) { + var row struct { + MaxAttemptNo int + } + if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}). + Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no"). + Where("refund_id = ?", refundID).Scan(&row).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败") + } + return row.MaxAttemptNo + 1, nil +} + +// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。 +// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。 +func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) { + snapshot := map[string]any{ + "order_type": order.OrderType, + "asset_identifier": order.AssetIdentifier, + } + if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 { + var usage model.PackageUsage + if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败") + } + } else { + snapshot["package_usage"] = map[string]any{ + "id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName, + "usage_type": usage.UsageType, "status": usage.Status, + "data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB, + "activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, + } + } + } + encoded, err := sonic.Marshal(snapshot) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败") + } + return datatypes.JSON(encoded), nil +} + +// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。 +func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error { + result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}). + Where("id = ? AND approval_instance_id IS NULL", attempt.ID). + Update("approval_instance_id", instanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批尝试实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款审批尝试实例关联已变化") + } + attempt.ApprovalInstanceID = &instanceID + return nil +} + +// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。 +// 既有 approval_instance_id 在该函数外单独回写,保持「首次接入企业微信审批的实例」语义不变。 +func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error { + updates := map[string]any{ + "latest_attempt_id": attempt.ID, + "latest_approval_instance_id": instanceID, + "updated_at": time.Now().UTC(), + } + if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败") + } + refund.LatestAttemptID = attempt.ID + refund.LatestApprovalInstanceID = instanceID + return nil +} + func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) { submitterSnapshot, err := sonic.Marshal(map[string]any{ "account_id": account.ID, "account_name": account.Username, "user_type": account.UserType, @@ -247,7 +524,7 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt constants.ApprovalFieldOrderNo: refund.OrderNo, constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, constants.ApprovalFieldAssetType: refund.OrderType, - constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount), + constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount), constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount), constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey), constants.ApprovalFieldRefundReason: refund.RefundReason, diff --git a/internal/application/refundapproval/resolve.go b/internal/application/refundapproval/resolve.go new file mode 100644 index 0000000..bc3ff8b --- /dev/null +++ b/internal/application/refundapproval/resolve.go @@ -0,0 +1,101 @@ +package refundapproval + +import ( + "context" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。 +// +// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。 +// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属: +// 必须同时匹配 approval_instance_id,才能唯一确定是尝试记录还是退款申请。 +// +// 解析顺序固定为「尝试记录优先、退款申请兜底」: +// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID; +// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID; +// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。 +// +// attempt 在存量兼容路径下为 nil。 +func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) { + if tx == nil || businessID == 0 || instanceID == 0 { + return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效") + } + + var attempt model.RefundRequestAttempt + err := tx.WithContext(ctx). + Where("id = ? AND approval_instance_id = ?", businessID, instanceID). + First(&attempt).Error + switch { + case err == nil: + var refund model.RefundRequest + if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败") + } + return &refund, &attempt, nil + case err != gorm.ErrRecordNotFound: + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败") + } + + var refund model.RefundRequest + err = tx.WithContext(ctx). + Where("id = ? AND approval_instance_id = ?", businessID, instanceID). + First(&refund).Error + switch { + case err == nil: + return &refund, nil, nil + case err == gorm.ErrRecordNotFound: + return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致") + default: + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败") + } +} + +// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。 +func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) { + refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID) + if err != nil { + return 0, err + } + return refund.ID, nil +} + +// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。 +// +// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到 +// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性 +// 校验的常规解析必然不命中。本函数只承认这一种在途形态: +// +// attempt.id = businessID AND attempt.approval_instance_id IS NULL +// +// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。 +func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) { + if tx == nil || businessID == 0 { + return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效") + } + var attempt model.RefundRequestAttempt + err := tx.WithContext(ctx). + Where("id = ? AND approval_instance_id IS NULL", businessID). + First(&attempt).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败") + } + var refund model.RefundRequest + if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败") + } + return &refund, &attempt, nil +} diff --git a/internal/application/refundchannel/audit.go b/internal/application/refundchannel/audit.go new file mode 100644 index 0000000..cba8c93 --- /dev/null +++ b/internal/application/refundchannel/audit.go @@ -0,0 +1,32 @@ +package refundchannel + +import ( + "context" + stderrors "errors" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AuditWriter 写退款渠道调用与恢复的可审计事实。 +// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。 +type AuditWriter interface { + WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error +} + +// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。 +// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。 +type CompletionNotifier interface { + AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error +} + +// appErrorCode 读取应用错误码;非应用错误返回 0。 +func appErrorCode(err error) int { + var appErr *errors.AppError + if stderrors.As(err, &appErr) { + return appErr.Code + } + return 0 +} diff --git a/internal/application/refundchannel/event.go b/internal/application/refundchannel/event.go new file mode 100644 index 0000000..26f3072 --- /dev/null +++ b/internal/application/refundchannel/event.go @@ -0,0 +1,75 @@ +package refundchannel + +import ( + "context" + "strconv" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" +) + +// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。 +const EventRefundChannelRefund = "refund.channel.refund.requested" + +// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。 +const refundChannelPayloadVersion = 1 + +// Payload 是渠道原路退款事件的载荷。 +type Payload struct { + RefundID uint `json:"refund_id"` + OrderID uint `json:"order_id"` +} + +// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。 +// 同一退款申请使用稳定事件 ID,重复投递不会重复创建事实。 +func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error { + if repository == nil { + return gorm.ErrInvalidDB + } + value := strconv.FormatUint(uint64(refundID), 10) + _, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: outboxid.Stable(EventRefundChannelRefund+":", value), + EventType: EventRefundChannelRefund, + PayloadVersion: refundChannelPayloadVersion, + AggregateType: "refund", AggregateID: value, + ResourceType: "refund", ResourceID: value, + BusinessKey: EventRefundChannelRefund + ":" + value, + Payload: Payload{RefundID: refundID, OrderID: orderID}, + }) + return err +} + +// Consumer 把渠道原路退款事件转成一次性资金动作。 +type Consumer struct { + service *Service +} + +// NewConsumer 创建渠道原路退款事件消费者。 +func NewConsumer(service *Service) *Consumer { + return &Consumer{service: service} +} + +// Consume 幂等执行渠道原路退款;重复投递由退款申请状态与渠道请求号共同兜住。 +func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + var payload Payload + if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil { + return outbox.Permanent(err) + } + if envelope.EventType != EventRefundChannelRefund || + envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 { + return outbox.Permanent(gorm.ErrInvalidData) + } + if c == nil || c.service == nil { + return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置") + } + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID}) + return c.service.Execute(ctx, payload.RefundID) +} + +// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。 +var _ outbox.EventConsumer = (*Consumer)(nil) diff --git a/internal/application/refundchannel/number.go b/internal/application/refundchannel/number.go new file mode 100644 index 0000000..8a3b955 --- /dev/null +++ b/internal/application/refundchannel/number.go @@ -0,0 +1,71 @@ +package refundchannel + +import ( + "crypto/rand" + "strconv" + "strings" + "time" +) + +// 渠道退款请求号生成规则参数。 +const ( + // channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。 + channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + // channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。 + channelRefundRequestNoRandomLen = 18 + // channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。 + channelRefundRequestNoLength = 30 + // channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0,超过取前 4 位。 + channelRefundRequestNoPrefixLen = 4 +) + +// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。 +var shanghaiLocation = time.FixedZone("CST", 8*3600) + +// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。 +// +// 规则与富友流水号完全一致(本包不引入渠道 SDK,因此在此独立实现同一规则): +// 前缀规整为 4 位(不足左侧补 0,超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母 +// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传 +// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。 +func BuildChannelRefundRequestNo(prefix string, now time.Time) string { + var builder strings.Builder + builder.Grow(channelRefundRequestNoLength) + builder.WriteString(normalizeChannelRefundPrefix(prefix)) + builder.WriteString(now.In(shanghaiLocation).Format("20060102")) + + buffer := make([]byte, channelRefundRequestNoRandomLen) + if _, err := rand.Read(buffer); err != nil { + // 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。 + builder.WriteString(fallbackRandomSegment(now)) + return builder.String() + } + for _, value := range buffer { + builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)]) + } + return builder.String() +} + +// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0,超过取前 4 位。 +func normalizeChannelRefundPrefix(prefix string) string { + normalized := strings.TrimSpace(prefix) + if len(normalized) >= channelRefundRequestNoPrefixLen { + return normalized[:channelRefundRequestNoPrefixLen] + } + return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized +} + +// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。 +func fallbackRandomSegment(now time.Time) string { + segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36)) + segment = strings.Map(func(char rune) rune { + if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') { + return char + } + return 'X' + }, segment) + if len(segment) >= channelRefundRequestNoRandomLen { + return segment[:channelRefundRequestNoRandomLen] + } + return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment)) +} diff --git a/internal/application/refundchannel/recover.go b/internal/application/refundchannel/recover.go new file mode 100644 index 0000000..bf7a16e --- /dev/null +++ b/internal/application/refundchannel/recover.go @@ -0,0 +1,193 @@ +package refundchannel + +import ( + "context" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Stats 是一次恢复扫描的可观察结果。 +// +// Scanned 为本次扫描到的申请数;Confirmed 为回填为渠道明确成功的申请数; +// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口 +// 超期后终止本次渠道执行);Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败); +// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。 +type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int } + +// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。 +func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) { + stats := Stats{} + if err := s.requireReady(); err != nil { + return stats, err + } + var refunds []model.RefundRequest + if err := s.db.WithContext(ctx). + // 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。 + Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?", + model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0). + Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil { + return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败") + } + stats.Scanned = len(refunds) + if len(refunds) == 0 { + return stats, nil + } + payments, err := s.loadPaidPayments(ctx, refunds) + if err != nil { + return stats, err + } + now := s.now().UTC() + var firstErr error + for index := range refunds { + if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil { + stats.Skipped++ + s.logger.Warn("渠道原路退款恢复单条处理失败", + zap.Uint("refund_id", refunds[index].ID), zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + } + return stats, firstErr +} + +// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。 +func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error { + target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID]) + if err != nil { + return err + } + if failureReason != "" { + // 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。 + stats.Pending++ + s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询", + zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason)) + return nil + } + if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window { + return s.flagQueryWindowExpired(ctx, refund, reason, now, stats) + } + callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout) + defer cancel() + result, callErr := s.refunder.Query(callCtx, target) + if callErr != nil { + // 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。 + stats.Pending++ + return nil + } + applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now) + if err != nil { + return err + } + if !applied { + stats.Skipped++ + return nil + } + switch result.State { + case StateSuccess: + stats.Confirmed++ + case StateFailed: + stats.Failed++ + default: + stats.Pending++ + } + return nil +} + +// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。 +// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。 +// +// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束; +// - 微信 v2:受理响应不含退款状态、渠道侧无查询时限,此处按本地阈值放弃轮询并转人工, +// 避免一笔未知结果被无限重试。 +func queryWindowPolicy(providerType string) (time.Duration, string) { + switch providerType { + case model.ProviderTypeFuiou: + return fuiouQueryWindow, anomalyReasonFuiouQueryWindow + case model.ProviderTypeWechatV2: + return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow + default: + return 0, "" + } +} + +// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。 +// +// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的 +// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。 +// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定), +// 而 status 只表达该尝试的渠道路径已终止。 +// +// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次 +// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。 +func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error { + stats.Failed++ + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + updated := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0", + refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing). + // UpdateColumns 不隐式推进 updated_at:窗口起算点必须保留在进入原路处理中的时刻, + // 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。 + UpdateColumns(map[string]any{ + "status": model.RefundStatusChannelFailed, + "channel_refund_status": constants.RefundChannelStatusFailed, + "failure_reason": constants.RefundFailureTimeoutUnknown, + "anomaly_flag": 1, + "anomaly_reason": reason, + }) + if updated.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败") + } + if updated.RowsAffected != 1 { + return nil + } + refund.Status = model.RefundStatusChannelFailed + refund.ChannelRefundStatus = constants.RefundChannelStatusFailed + refund.FailureReason = constants.RefundFailureTimeoutUnknown + refund.AnomalyFlag = 1 + refund.AnomalyReason = reason + return s.audit.WriteRefundChannelResult(ctx, tx, refund, + constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对") + }) + if err != nil { + if appErrorCode(err) != 0 { + return err + } + return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败") + } + return nil +} + +// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。 +// 结果未知的回写不会推进 updated_at,因此窗口始终从进入原路处理中的时点起算。 +func refundWindowStart(refund *model.RefundRequest) time.Time { + if refund.ChannelRefundedAt != nil { + return refund.ChannelRefundedAt.UTC() + } + return refund.UpdatedAt.UTC() +} + +// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。 +func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) { + orderIDs := make([]uint, 0, len(refunds)) + for index := range refunds { + orderIDs = append(orderIDs, refunds[index].OrderID) + } + var payments []model.Payment + if err := s.db.WithContext(ctx). + Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). + Order("id ASC").Find(&payments).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败") + } + latest := make(map[uint]*model.Payment, len(payments)) + for index := range payments { + latest[payments[index].OrderID] = &payments[index] + } + return latest, nil +} diff --git a/internal/application/refundchannel/service.go b/internal/application/refundchannel/service.go new file mode 100644 index 0000000..a68c396 --- /dev/null +++ b/internal/application/refundchannel/service.go @@ -0,0 +1,701 @@ +// Package refundchannel 执行与恢复渠道原路退款。 +// +// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、 +// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的 +// Refunder 实现,本包不依赖任何渠道 SDK,也绝不在数据库事务内发起渠道调用。 +package refundchannel + +import ( + "context" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// State 是渠道调用的稳定结果状态。 +type State string + +const ( + StateSuccess State = "success" // 渠道明确成功 + StateFailed State = "failed" // 渠道明确失败 + StateUnknown State = "unknown" // 超时或结果未确认,可恢复 +) + +// 渠道原路退款的固定运行参数。 +const ( + // recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。 + recoveryBatchSize = 50 + // fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。 + fuiouQueryWindow = 72 * time.Hour + // wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。 + // 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限, + // 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。 + wechatV2QueryWindow = 7 * 24 * time.Hour + // channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。 + channelCallTimeout = 30 * time.Second + // fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。 + // 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK,因此在此固定回传该冻结值。 + fuiouOrderTypeWechat = "WECHAT" + // anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。 + anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对" + // anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。 + anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对" + // failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。 + failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复" + // failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。 + failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款" + // failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。 + failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款" + // failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。 + failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款" + // failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。 + failureMessageMaxRunes = 480 + // providerTypeAlipay 是支付宝商户的 provider_type 取值;model 未定义该常量, + // 取值与商户凭证管理保持的 "alipay" 完全一致。 + providerTypeAlipay = "alipay" +) + +// Target 是执行一次渠道原路退款所需的全部冻结事实。 +type Target struct { + RefundID uint + RefundNo string + OrderID uint + OrderNo string + ProviderType string // model.ProviderType* + Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志 + PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no) + ChannelTradeNo string // 原支付单渠道交易流水 + ChannelOrderType string // 富友原交易 order_type + PaidAt *time.Time + PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值 + RefundAmount int64 + FrozenActualReceivedAmount int64 + ChannelRefundRequestNo string +} + +// Result 是渠道调用或查询的映射结果。 +type Result struct { + State State + ChannelRefundNo string // 渠道退款流水号 + ChannelRefundAmount int64 // 渠道退款金额(分) + SettledAt string // 渠道结算日期原文,可空 + FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值 + FailureMessage string // 安全摘要,不得含凭证或报文原文 +} + +// Refunder 是渠道原路退款 Port,由基础设施层按服务商类型实现。 +type Refunder interface { + // Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。 + Refund(ctx context.Context, target Target) (Result, error) + // Query 只查询渠道退款状态,不得发起资金动作。 + Query(ctx context.Context, target Target) (Result, error) +} + +// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。 +type MerchantLoader interface { + LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) + LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) +} + +// Service 执行与恢复原路退款。 +type Service struct { + db *gorm.DB + loader MerchantLoader + refunder Refunder + audit AuditWriter + notifier CompletionNotifier + logger *zap.Logger + now func() time.Time +} + +// NewService 创建渠道原路退款用例。 +func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service { + return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now} +} + +// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。 +func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service { + if s == nil { + return s + } + s.notifier = notifier + return s +} + +// SetLogger 注入渠道原路退款运行日志。 +func (s *Service) SetLogger(logger *zap.Logger) *Service { + if s == nil { + return s + } + if logger == nil { + logger = zap.NewNop() + } + s.logger = logger + return s +} + +// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。 +// +// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用, +// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。 +func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error { + if s == nil || tx == nil || refund == nil || refund.ID == 0 { + return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效") + } + if refund.Method != constants.RefundMethodOriginalRoute { + return nil + } + requestNo := "" + if attempt != nil { + requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo) + } + if requestNo == "" { + // 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。 + requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now()) + } + now := s.now().UTC() + updated := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). + Updates(map[string]any{ + "status": model.RefundStatusChannelProcessing, + "channel_refund_status": constants.RefundChannelStatusProcessing, + "channel_refund_request_no": requestNo, + "updated_at": now, + }) + if updated.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败") + } + if updated.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中") + } + if attempt != nil && attempt.ID != 0 { + write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}). + Where("id = ?", attempt.ID). + Update("channel_refund_request_no", requestNo) + if write.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败") + } + if write.RowsAffected != 1 { + s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID)) + } + attempt.ChannelRefundRequestNo = requestNo + } + if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败") + } + refund.Status = model.RefundStatusChannelProcessing + refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing + refund.ChannelRefundRequestNo = requestNo + return nil +} + +// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。 +// +// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证: +// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund; +// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放), +// 此时只查询渠道结果并回填,绝不再次提交资金动作。 +func (s *Service) Execute(ctx context.Context, refundID uint) error { + if err := s.requireReady(); err != nil { + return err + } + if refundID == 0 { + return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识") + } + facts, proceed, err := s.loadExecutionFacts(ctx, refundID) + if err != nil { + return err + } + if !proceed { + return nil + } + payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID) + if err != nil { + return err + } + target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment) + if err != nil { + return err + } + now := s.now().UTC() + if failureReason != "" { + // 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。 + if _, err := s.writeback(ctx, facts.refund, target, Result{ + State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage, + }, constants.AuditActionRefundChannelCalled, now); err != nil { + return err + } + return nil + } + // 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。 + claimed, err := s.claimChannelSubmission(ctx, refundID, now) + if err != nil { + return err + } + callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout) + defer cancel() + if !claimed { + // 已提交过:只查询渠道结果,绝不再次提交资金动作。 + result, callErr := s.refunder.Query(callCtx, target) + if callErr != nil { + // 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。 + return nil + } + s.logger.Info("渠道退款请求已提交过,本次仅查询结果", + zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo)) + _, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now) + return err + } + result, callErr := s.refunder.Refund(callCtx, target) + if callErr != nil { + // 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。 + result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown} + } + _, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now) + return err +} + +// claimChannelSubmission 以条件更新认领本次渠道退款提交权。 +// +// 返回 true 表示调用方获得提交权、可以调用渠道退款接口;false 表示该尝试在此之前 +// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中 +// 为谓词,因此并发执行也至多有一次认领成功。 +func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) { + claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ? AND channel_submitted_at IS NULL", + refundID, model.RefundStatusChannelProcessing). + UpdateColumn("channel_submitted_at", now) + if claimed.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败") + } + return claimed.RowsAffected == 1, nil +} + +// executionFacts 是一次渠道执行所需的本地冻结事实。 +type executionFacts struct { + refund *model.RefundRequest + attempt *model.RefundRequestAttempt +} + +// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。 +// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。 +func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) { + facts := &executionFacts{} + proceed := false + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var refund model.RefundRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "退款申请不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") + } + facts.refund = &refund + if refund.Method != constants.RefundMethodOriginalRoute { + s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method)) + return nil + } + // 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。 + if refund.Status != model.RefundStatusChannelProcessing || + refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded { + return nil + } + attempt, err := loadAttempt(ctx, tx, &refund) + if err != nil { + return err + } + facts.attempt = attempt + proceed = true + return nil + }) + if err != nil { + return nil, false, err + } + return facts, proceed, nil +} + +// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。 +func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) { + var attempt model.RefundRequestAttempt + query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}) + if refund.LatestAttemptID != 0 { + if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败") + } + return &attempt, nil + } + if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败") + } + return &attempt, nil +} + +// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。 +func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) { + var payment model.Payment + if err := s.db.WithContext(ctx). + Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). + Order("id DESC").First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败") + } + return &payment, nil +} + +// buildTarget 在事务外组装渠道调用目标。 +// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。 +func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) { + target := Target{ + RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo, + RefundAmount: resolveRefundAmount(refund, attempt), + FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt), + ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt), + } + if target.ChannelRefundRequestNo == "" { + // 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。 + return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil + } + if payment == nil { + return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil + } + target.PaymentNo = strings.TrimSpace(payment.PaymentNo) + target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo) + target.PaidAt = payment.PaidAt + target.PaidAmount = payment.Amount + if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 || + target.RefundAmount > target.FrozenActualReceivedAmount { + return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil + } + config, providerType, err := s.loadChannelConfig(ctx, payment) + if err != nil { + if !credentialFailure(err) { + return target, "", "", err + } + return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil + } + if !credentialComplete(providerType, config) { + return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil + } + target.ProviderType = providerType + target.Config = config + if providerType == model.ProviderTypeFuiou { + target.ChannelOrderType = fuiouOrderTypeWechat + } + return target, "", "", nil +} + +// loadChannelConfig 加载原支付单实际收款商户的当前凭证。 +// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权;merchant_id 为空仅表示数据留存期内的 +// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。 +func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) { + if payment.MerchantID != nil { + merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID) + if err != nil { + return nil, "", err + } + if merchant == nil { + return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在") + } + // 仅微信直连(v3/v2)需要全局微信授权配置中的 AppID;其他服务商传 nil 避免无谓失败。 + var authorization *model.WechatAuthorization + if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 { + authorization, err = s.loader.LoadAuthorization(ctx) + if err != nil { + return nil, "", err + } + } + config, err := merchantpayment.MerchantConfig(merchant, authorization) + if err != nil { + return nil, "", err + } + return config, merchant.ProviderType, nil + } + if payment.PaymentConfigID == nil { + return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置") + } + var legacy model.WechatConfig + if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用") + } + return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败") + } + return &legacy, legacy.ProviderType, nil +} + +// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。 +// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。 +// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。 +// +// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与 +// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund)请求需要双向 +// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为 +// 不可用,补录证书后即可用。判定结果不提供人工开关。 +func RefundCredentialIssue(providerType string, config *model.WechatConfig) string { + if config == nil { + return failureMessageCredential + } + switch providerType { + case model.ProviderTypeWechat: + if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, + config.WxKeyContent, config.WxSerialNo) { + return "冻结微信商户退款凭证不完整" + } + case model.ProviderTypeWechatV2: + // v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。 + if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) { + return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)" + } + case model.ProviderTypeFuiou: + if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, + config.FyPublicKey, config.FyAPIURL) { + return "冻结富友商户退款凭证不完整" + } + case providerTypeAlipay: + if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) { + return "冻结支付宝商户退款凭证不完整" + } + default: + return "冻结商户不支持原路退款" + } + return "" +} + +// credentialComplete 判断该服务商类型的退款必需凭证是否完整。 +func credentialComplete(providerType string, config *model.WechatConfig) bool { + return RefundCredentialIssue(providerType, config) == "" +} + +func completeFields(values ...string) bool { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return false + } + } + return true +} + +// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。 +func credentialFailure(err error) bool { + switch appErrorCode(err) { + case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable: + return true + default: + return false + } +} + +// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。 +// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号, +// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。 +func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string { + if attempt != nil { + if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" { + return requestNo + } + } + return strings.TrimSpace(refund.ChannelRefundRequestNo) +} + +// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。 +func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 { + if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 { + return *refund.ApprovedRefundAmount + } + if refund.RequestedRefundAmount > 0 { + return refund.RequestedRefundAmount + } + if attempt != nil { + return attempt.RefundAmount + } + return 0 +} + +// resolveFrozenAmount 取本次原路退款的冻结实收金额。 +func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 { + if refund.FrozenActualReceivedAmount > 0 { + return refund.FrozenActualReceivedAmount + } + if attempt != nil { + return attempt.FrozenActualReceivedAmount + } + return 0 +} + +// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。 +// applied 为 false 表示记录已被并发推进,本次不改动任何状态。 +func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) { + applied := false + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + applied, err = s.applyResult(ctx, tx, refund, target, result, action, now) + return err + }) + if err != nil { + return false, err + } + if !applied { + s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进", + zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State))) + } + return applied, nil +} + +// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。 +// 所有状态流转都以 status = 原路处理中 为谓词,RowsAffected 为 0 表示并发已推进该记录。 +func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) { + update := map[string]any{} + syncRefund := func() {} + orderRefunded := false + reason := result.FailureReason + message := "" + switch result.State { + case StateSuccess: + amount := result.ChannelRefundAmount + if amount <= 0 { + amount = target.RefundAmount + } + update["status"] = model.RefundStatusApproved + update["channel_refund_status"] = constants.RefundChannelStatusSucceeded + update["channel_refund_no"] = result.ChannelRefundNo + update["channel_refund_amount"] = amount + update["channel_refunded_at"] = now + update["processed_at"] = now + update["failure_reason"] = "" + update["failure_message"] = "" + update["updated_at"] = now + orderRefunded = true + message = "渠道原路退款明确成功" + syncRefund = func() { + refund.Status = model.RefundStatusApproved + refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded + refund.ChannelRefundNo = result.ChannelRefundNo + refund.ChannelRefundAmount = amount + refund.ChannelRefundedAt = &now + refund.ProcessedAt = &now + refund.FailureReason = "" + refund.FailureMessage = "" + } + case StateFailed: + if reason == "" { + reason = constants.RefundFailureChannelRejected + } + message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason) + failureMessage := safeMessage(result.FailureMessage, message) + update["status"] = model.RefundStatusChannelFailed + update["channel_refund_status"] = constants.RefundChannelStatusFailed + update["failure_reason"] = reason + update["failure_message"] = failureMessage + update["updated_at"] = now + syncRefund = func() { + refund.Status = model.RefundStatusChannelFailed + refund.ChannelRefundStatus = constants.RefundChannelStatusFailed + refund.FailureReason = reason + refund.FailureMessage = failureMessage + } + default: + // 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。 + // 不修改 updated_at,使富友查询窗口从进入原路处理中的时点起算。 + reason = constants.RefundFailureTimeoutUnknown + message = "渠道原路退款结果未确认,保持处理中" + failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown) + update["channel_refund_status"] = constants.RefundChannelStatusProcessing + update["failure_reason"] = reason + update["failure_message"] = failureMessage + syncRefund = func() { + refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing + refund.FailureReason = reason + refund.FailureMessage = failureMessage + } + } + // UpdateColumns 不会隐式推进 updated_at:结果未知时必须保留进入原路处理中的时点, + // 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。 + updated := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing). + UpdateColumns(update) + if updated.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败") + } + if updated.RowsAffected != 1 { + return false, nil + } + syncRefund() + if orderRefunded { + if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil { + return false, err + } + // 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同: + // 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。 + if s.notifier != nil { + if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil { + return false, err + } + } + } + if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败") + } + return true, nil +} + +// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。 +// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。 +func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error { + updated := tx.WithContext(ctx).Model(&model.Order{}). + Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid). + Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now}) + if updated.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败") + } + if updated.RowsAffected == 1 { + return nil + } + var order model.Order + if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败") + } + if order.PaymentStatus != model.PaymentStatusRefunded { + s.logger.Warn("订单支付状态未置为已退款", + zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus)) + } + return nil +} + +// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。 +func safeMessage(message, fallback string) string { + text := strings.TrimSpace(message) + if text == "" { + text = fallback + } + runes := []rune(text) + if len(runes) > failureMessageMaxRunes { + text = string(runes[:failureMessageMaxRunes]) + } + return text +} + +// requireReady 校验渠道原路退款的全部依赖已配置。 +func (s *Service) requireReady() error { + if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置") + } + return nil +} diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index c4ef9d4..0fa4e5b 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -13,6 +13,7 @@ import ( exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" + refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -332,6 +333,16 @@ func initServices(s *stores, deps *Dependencies) *services { refundService.SetNotificationOutbox(walletOutbox) refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis)) refundService.SetLifecycleAudit(auditWriter) + // 渠道原路退款的登记与执行共用同一用例:API 侧只登记待执行事实与可靠事件, + // 真正的渠道调用由 Worker 消费该事件执行。 + refundService.SetChannelRefundService( + refundchannelApp.NewService( + deps.DB, + merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis), + paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger), + auditWriter, + ).SetLogger(deps.Logger).SetCompletionNotifier(refundService), + ) exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) exchangeService.SetAccessAudit(auditWriter) diff --git a/internal/exporter/refund_scene.go b/internal/exporter/refund_scene.go index 2066b94..4b1d81d 100644 --- a/internal/exporter/refund_scene.go +++ b/internal/exporter/refund_scene.go @@ -39,7 +39,9 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) { return []string{ "退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)", - "实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注", + "实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式", + "冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记", + "退款原因", "审批备注", "审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证", }, nil } @@ -61,6 +63,13 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse r.requested_refund_amount, r.approved_refund_amount, r.status, + r.method, + r.frozen_actual_received_amount, + r.channel_refund_status, + r.channel_refund_no, + r.channel_refund_amount, + r.failure_reason, + r.anomaly_flag, r.refund_reason, r.remark, r.commission_deducted, @@ -109,6 +118,13 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse formatMoneyYuan(item.RequestedRefundAmount), formatOptionalMoneyYuan(item.ApprovedRefundAmount), constants.GetRefundStatusName(item.Status), + constants.RefundMethodName(item.Method), + formatMoneyYuan(item.FrozenActualReceivedAmount), + constants.RefundChannelStatusName(item.ChannelRefundStatus), + item.ChannelRefundNo, + formatMoneyYuan(item.ChannelRefundAmount), + constants.RefundFailureReasonName(item.FailureReason), + formatRefundAnomalyFlag(item.AnomalyFlag), item.RefundReason, item.Remark, formatRefundApprovalSource(item.ApprovalProvider), @@ -145,28 +161,35 @@ func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *go } type refundExportRow struct { - RefundNo string `gorm:"column:refund_no"` - ShopName string `gorm:"column:shop_name"` - OrderNo string `gorm:"column:order_no"` - OrderType string `gorm:"column:order_type"` - AssetIdentifier string `gorm:"column:asset_identifier"` - PackageName string `gorm:"column:package_name"` - OriginalAmount *int64 `gorm:"column:original_amount"` - ActualReceivedAmount int64 `gorm:"column:actual_received_amount"` - RefundableAmount *int64 `gorm:"column:refundable_amount"` - RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"` - ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"` - Status int `gorm:"column:status"` - RefundReason string `gorm:"column:refund_reason"` - Remark string `gorm:"column:remark"` - ApprovalProvider *string `gorm:"column:approval_provider"` - ApprovalStatus *int `gorm:"column:approval_status"` - CommissionDeducted bool `gorm:"column:commission_deducted"` - AssetReset bool `gorm:"column:asset_reset"` - CreatedAt time.Time `gorm:"column:created_at"` - ProcessedAt *time.Time `gorm:"column:processed_at"` - SubmitterName string `gorm:"column:submitter_name"` - VoucherKeys string `gorm:"column:voucher_keys"` + RefundNo string `gorm:"column:refund_no"` + ShopName string `gorm:"column:shop_name"` + OrderNo string `gorm:"column:order_no"` + OrderType string `gorm:"column:order_type"` + AssetIdentifier string `gorm:"column:asset_identifier"` + PackageName string `gorm:"column:package_name"` + OriginalAmount *int64 `gorm:"column:original_amount"` + ActualReceivedAmount int64 `gorm:"column:actual_received_amount"` + RefundableAmount *int64 `gorm:"column:refundable_amount"` + RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"` + ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"` + Status int `gorm:"column:status"` + Method string `gorm:"column:method"` + FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount"` + ChannelRefundStatus int `gorm:"column:channel_refund_status"` + ChannelRefundNo string `gorm:"column:channel_refund_no"` + ChannelRefundAmount int64 `gorm:"column:channel_refund_amount"` + FailureReason string `gorm:"column:failure_reason"` + AnomalyFlag int `gorm:"column:anomaly_flag"` + RefundReason string `gorm:"column:refund_reason"` + Remark string `gorm:"column:remark"` + ApprovalProvider *string `gorm:"column:approval_provider"` + ApprovalStatus *int `gorm:"column:approval_status"` + CommissionDeducted bool `gorm:"column:commission_deducted"` + AssetReset bool `gorm:"column:asset_reset"` + CreatedAt time.Time `gorm:"column:created_at"` + ProcessedAt *time.Time `gorm:"column:processed_at"` + SubmitterName string `gorm:"column:submitter_name"` + VoucherKeys string `gorm:"column:voucher_keys"` } func formatRefundAssetType(orderType string) string { @@ -200,7 +223,19 @@ func formatRefundProcessingStatus(status int, commissionDeducted, assetReset boo return "已完成" } return "处理中" + case model.RefundStatusChannelProcessing: + return "原路退款处理中" + case model.RefundStatusChannelFailed: + return "原路退款失败待人工处理" default: return "未知" } } + +// formatRefundAnomalyFlag 将异常标记转为导出用中文描述。 +func formatRefundAnomalyFlag(flag int) string { + if flag == 0 { + return "无异常" + } + return "有异常" +} diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go index 266c60c..445ec9b 100644 --- a/internal/infrastructure/audit/approval.go +++ b/internal/infrastructure/audit/approval.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -54,7 +55,7 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi if err != nil { return nil, err } - resources = append(resources, business) + resources = append(resources, business...) resources = append(resources, approvalSubmitterResource(change)) seenIntegrationIDs := make(map[string]struct{}, len(change.IntegrationIDs)) for _, integrationID := range change.IntegrationIDs { @@ -82,30 +83,66 @@ func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.Audi return resources, nil } -func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) (ResourceInput, error) { +// approvalBusinessResource 构造审批关联的业务资源。 +// +// 退款审批的业务标识在尝试模式下指向退款审批尝试记录、存量模式下指向退款申请本身, +// 因此该分支统一经 refundapproval.ResolveRefundInTx 解析业务归属:主资源固定为解析出的 +// 退款单,尝试记录存在时再追加一条引用资源,使两种语义在同一审计事件内都可追溯。 +func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) ([]ResourceInput, error) { id := strconv.FormatUint(uint64(businessID), 10) switch businessType { case constants.ApprovalBusinessTypeRefund: - var refund model.RefundRequest - if err := tx.WithContext(ctx).First(&refund, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联退款单失败") + refund, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, businessID, instanceID) + if err != nil { + // 提交事务内的「审批申请」审计先于 attachAttemptInstance 执行:此刻尝试记录已写入, + // 但 approval_instance_id 仍为空,共享解析器的实例一致性校验必然不命中。 + // 这里只补一条尚未回写实例的尝试记录解析,其余不一致仍按解析器的冲突错误失败关闭。 + refund, attempt, err = resolvePendingRefundAttempt(ctx, tx, businessID, err) + if err != nil { + return nil, err + } } - return ResourceInput{ - Type: constants.AuditResourceRefund, ID: &id, Key: refund.RefundNo, DisplayName: refund.RefundNo, + refundID := strconv.FormatUint(uint64(refund.ID), 10) + resources := []ResourceInput{{ + Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, IdentitySnapshot: map[string]any{ "id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID, "order_no": refund.OrderNo, "order_type": refund.OrderType, "asset_identifier": refund.AssetIdentifier, "shop_id": refund.ShopID, "requested_refund_amount": refund.RequestedRefundAmount, "actual_received_amount": refund.ActualReceivedAmount, + "method": refund.Method, "frozen_actual_received_amount": refund.FrozenActualReceivedAmount, + "latest_attempt_id": refund.LatestAttemptID, "channel_refund_status": refund.ChannelRefundStatus, + "failure_reason": refund.FailureReason, "anomaly_flag": refund.AnomalyFlag, "approval_instance_id": instanceID, "status": refund.Status, }, - }, nil + }} + if attempt == nil { + return resources, nil + } + attemptID := strconv.FormatUint(uint64(attempt.ID), 10) + // 客户收款信息是自由文本、凭证是对象存储标识,两者都不进审计快照,只记录存在性与凭证数量。 + resources = append(resources, ResourceInput{ + Type: constants.AuditResourceRefundAttempt, ID: &attemptID, Key: attemptID, DisplayName: "退款审批尝试 " + attemptID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "refund_id": attempt.RefundID, "attempt_no": attempt.AttemptNo, + "method": attempt.Method, "refund_amount": attempt.RefundAmount, + "frozen_actual_received_amount": attempt.FrozenActualReceivedAmount, + "approval_instance_id": instanceID, + "channel_refund_request_no": attempt.ChannelRefundRequestNo, + "submitted_by_account_id": attempt.SubmittedByAccountID, + "customer_account_info_present": attempt.CustomerAccountInfo != "", + "customer_voucher_count": len(attempt.CustomerVoucherKeys), + "created_at": attempt.CreatedAt, + }, + }) + return resources, nil case constants.ApprovalBusinessTypeOfflineRecharge: var recharge model.AgentRechargeRecord if err := tx.WithContext(ctx).First(&recharge, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败") } - return ResourceInput{ + return []ResourceInput{{ Type: constants.AuditResourceAgentRecharge, ID: &id, Key: recharge.RechargeNo, DisplayName: recharge.RechargeNo, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, IdentitySnapshot: map[string]any{ @@ -114,13 +151,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "payment_method": recharge.PaymentMethod, "payment_channel": recharge.PaymentChannel, "approval_instance_id": instanceID, "status": recharge.Status, }, - }, nil + }}, nil case constants.ApprovalBusinessTypeEmployeeCollection: var attempt model.EmployeeCollectionApplicationAttempt if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败") } - return ResourceInput{ + return []ResourceInput{{ Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &id, Key: id, DisplayName: "审批尝试 " + id, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, @@ -128,13 +165,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo, "paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID, }, - }, nil + }}, nil case constants.ApprovalBusinessTypeAgentDistribution: var registration model.AgentDistributionRegistration if err := tx.WithContext(ctx).First(®istration, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败") } - return ResourceInput{ + return []ResourceInput{{ Type: constants.AuditResourceAgentDistributionRegistration, ID: &id, Key: id, DisplayName: "扫码注册记录 " + id, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, @@ -142,13 +179,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "id": registration.ID, "parent_shop_id": registration.ParentShopID, "status": registration.Status, "approval_instance_id": instanceID, }, - }, nil + }}, nil case constants.ApprovalBusinessTypeWithdrawalQualification: var qualification model.WithdrawalQualification if err := tx.WithContext(ctx).First(&qualification, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败") } - return ResourceInput{ + return []ResourceInput{{ Type: constants.AuditResourceWithdrawalQualification, ID: &id, Key: id, DisplayName: "提现资料资格版本 " + id, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, @@ -157,13 +194,13 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "subject_type": qualification.SubjectType, "status": qualification.Status, "approval_instance_id": instanceID, }, - }, nil + }}, nil case constants.ApprovalBusinessTypeCommissionWithdrawal: var attempt model.CommissionWithdrawalRequestAttempt if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil { - return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败") } - return ResourceInput{ + return []ResourceInput{{ Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id, Key: id, DisplayName: "提现审批尝试 " + id, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, @@ -171,12 +208,26 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo, "amount": attempt.Amount, "approval_instance_id": instanceID, }, - }, nil + }}, nil default: - return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源") + return nil, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源") } } +// resolvePendingRefundAttempt 解析尚未回写审批实例的退款审批尝试记录。 +// +// 只在共享解析器返回冲突时调用:审批申请审计与「尝试记录回写审批实例」同事务, +// 但审计先执行,因此 business_id 指向的尝试记录此刻 approval_instance_id 仍为空。 +// 该形态由 refundapproval.ResolveRefundForApprovalRequestInTx 单独承认,其余情况 +// 原样返回解析器的冲突错误,不放宽为任意未回写记录。 +func resolvePendingRefundAttempt(ctx context.Context, tx *gorm.DB, businessID uint, cause error) (*model.RefundRequest, *model.RefundRequestAttempt, error) { + refund, attempt, err := refundapproval.ResolveRefundForApprovalRequestInTx(ctx, tx, businessID) + if err != nil { + return nil, nil, cause + } + return refund, attempt, nil +} + func approvalSubmitterResource(change approvalapp.AuditChange) ResourceInput { accountID := strconv.FormatUint(uint64(change.SubmitterAccountID), 10) identity := map[string]any{"id": change.SubmitterAccountID} diff --git a/internal/infrastructure/audit/refund_channel.go b/internal/infrastructure/audit/refund_channel.go new file mode 100644 index 0000000..0475ce4 --- /dev/null +++ b/internal/infrastructure/audit/refund_channel.go @@ -0,0 +1,105 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WriteRefundChannelResult 在调用方事务内写入渠道原路退款的调用、恢复与异常事实。 +// +// 主资源固定为退款单,渠道原路退款事实作为引用资源;快照只记录业务标识、金额、状态与 +// 结构化失败分类,不记录商户密钥、渠道报文或客户收款信息原文。摘要由调用方提供, +// 必须为不含凭证与渠道报文的简短中文说明。 +// +// 渠道调用与恢复都由后台任务触发,因此操作者固定为系统任务;审计上下文提供了更具体的 +// 操作者标识时沿用,避免任务未装配审计上下文时审计被拒绝并连带回滚业务事务。 +func (w *Writer) WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action, message string) error { + if refund == nil || refund.ID == 0 || refund.RefundNo == "" { + return errors.New(errors.CodeInvalidParam, "退款渠道审计资源不完整") + } + summary := message + if summary == "" { + summary = "渠道原路退款结果更新" + } + refundID := strconv.FormatUint(uint64(refund.ID), 10) + actorID, actorName := constants.AuditActorIDRefundChannel, "退款渠道原路退款任务" + if linkage := auditcontext.From(ctx); linkage.ActorID != "" { + actorID = linkage.ActorID + if linkage.ActorName != "" { + actorName = linkage.ActorName + } + } + resources := refundChannelResources(refund, refundID, summary) + // 用 AppendAndGet 而非 Append:渠道调用与恢复的审计属于「要求成功必达」的事实, + // 必须与业务事实同事务原子提交,失败时向调用方显式返回错误。 + if _, err := w.AppendAndGet(ctx, tx, AppendInput{ + ActionCode: action, Summary: summary, + Actor: ActorInput{Kind: constants.AuditActorSystemTask, ID: actorID, Name: actorName}, + Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, CorrelationID: refund.RefundNo, Resources: resources, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入退款渠道审计失败") + } + return nil +} + +// refundChannelResources 构造退款渠道审计资源:退款单为主资源,渠道原路退款事实为引用资源。 +func refundChannelResources(refund *model.RefundRequest, refundID, summary string) []ResourceInput { + return []ResourceInput{ + { + Type: constants.AuditResourceRefund, ID: &refundID, Key: refund.RefundNo, DisplayName: refund.RefundNo, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRefundTarget, + IdentitySnapshot: map[string]any{ + "id": refund.ID, "refund_no": refund.RefundNo, "method": refund.Method, + "frozen_actual_received_amount": refund.FrozenActualReceivedAmount, + "requested_refund_amount": refund.RequestedRefundAmount, + "approved_refund_amount": refund.ApprovedRefundAmount, + "status": refund.Status, + "channel_refund_status": refund.ChannelRefundStatus, + "channel_refund_no": refund.ChannelRefundNo, + "channel_refund_request_no": refund.ChannelRefundRequestNo, + "channel_refund_amount": refund.ChannelRefundAmount, + "failure_reason": refund.FailureReason, + "anomaly_flag": refund.AnomalyFlag, + }, + AfterData: channelRefundState(refund), + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, + }, + { + Type: constants.AuditResourceRefundChannelRefund, ID: &refundID, Key: refund.RefundNo, + DisplayName: "渠道原路退款 " + refund.RefundNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundChannelRefund, + IdentitySnapshot: map[string]any{ + "id": refund.ID, "refund_no": refund.RefundNo, + "channel_refund_status": refund.ChannelRefundStatus, + "channel_refund_no": refund.ChannelRefundNo, + "channel_refund_request_no": refund.ChannelRefundRequestNo, + "channel_refund_amount": refund.ChannelRefundAmount, + "failure_reason": refund.FailureReason, + "anomaly_flag": refund.AnomalyFlag, + }, + }, + } +} + +// channelRefundState 构造渠道原路退款状态快照,只包含状态与结构化失败分类。 +func channelRefundState(refund *model.RefundRequest) map[string]any { + state := map[string]any{ + "status": refund.Status, + "channel_refund_status": refund.ChannelRefundStatus, + "channel_refund_amount": refund.ChannelRefundAmount, + "failure_reason": refund.FailureReason, + "anomaly_flag": refund.AnomalyFlag, + } + if refund.ChannelRefundedAt != nil { + state["channel_refunded_at"] = *refund.ChannelRefundedAt + } + return state +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index aee01be..b885c69 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -310,6 +310,14 @@ func NewRegistry() *Registry { refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false) refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金") refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理") + // 退款审批尝试由后台账号提交与重提;终态与异常标记由企业微信审批消费任务写入(复用 refundAction 的 Worker 入口)。 + refundAttemptSubmitted := refundAction(constants.AuditActionRefundAttemptSubmitted, "提交退款审批尝试", false) + refundAttemptApproved := refundAction(constants.AuditActionRefundAttemptApproved, "通过退款审批尝试", true) + refundAttemptClosed := refundAction(constants.AuditActionRefundAttemptClosed, "关闭退款审批尝试", true) + refundAnomalyFlagged := refundAction(constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常", true) + // 渠道原路退款的调用与恢复都由 Worker 触发,与既有的退款系统动作入口一致。 + refundChannelCalled := refundSystemAction(constants.AuditActionRefundChannelCalled, "发起渠道原路退款") + refundChannelRecovered := refundSystemAction(constants.AuditActionRefundChannelRecovered, "恢复渠道原路退款结果") approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{ {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, }) @@ -631,6 +639,12 @@ func NewRegistry() *Registry { constants.AuditActionRefundResubmitted: refundResubmitted, constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated, constants.AuditActionRefundAssetProcessed: refundAssetProcessed, + constants.AuditActionRefundAttemptSubmitted: refundAttemptSubmitted, + constants.AuditActionRefundAttemptApproved: refundAttemptApproved, + constants.AuditActionRefundAttemptClosed: refundAttemptClosed, + constants.AuditActionRefundAnomalyFlagged: refundAnomalyFlagged, + constants.AuditActionRefundChannelCalled: refundChannelCalled, + constants.AuditActionRefundChannelRecovered: refundChannelRecovered, constants.AuditActionApprovalRequested: approvalRequested, constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced, constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered, @@ -791,7 +805,16 @@ func NewRegistry() *Registry { }, constants.AuditResourceRefund: { Type: constants.AuditResourceRefund, Name: "退款单", - IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "approval_instance_id", "status", "commission_deducted", "asset_reset"}, + IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "method", "frozen_actual_received_amount", "latest_attempt_id", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag", "approval_instance_id", "status", "commission_deducted", "asset_reset"}, + }, + // 退款审批尝试记录只登记冻结金额、方式与凭证数量,客户收款信息原文与凭证内容不进审计快照。 + constants.AuditResourceRefundAttempt: { + Type: constants.AuditResourceRefundAttempt, Name: "退款审批尝试记录", + IdentityFields: []string{"id", "refund_id", "attempt_no", "method", "refund_amount", "frozen_actual_received_amount", "approval_instance_id", "channel_refund_request_no", "submitted_by_account_id", "customer_account_info_present", "customer_voucher_count", "created_at"}, + }, + constants.AuditResourceRefundChannelRefund: { + Type: constants.AuditResourceRefundChannelRefund, Name: "渠道原路退款事实", + IdentityFields: []string{"id", "refund_no", "channel_refund_status", "channel_refund_no", "channel_refund_request_no", "channel_refund_amount", "failure_reason", "anomaly_flag"}, }, constants.AuditResourceEnterprise: { Type: constants.AuditResourceEnterprise, Name: "企业", diff --git a/internal/infrastructure/payment/refund_adapter.go b/internal/infrastructure/payment/refund_adapter.go new file mode 100644 index 0000000..dd2c3af --- /dev/null +++ b/internal/infrastructure/payment/refund_adapter.go @@ -0,0 +1,462 @@ +package payment + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + "go.uber.org/zap" + + refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/alipay" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/fuiou" + wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat" +) + +// fuiouShanghaiLocation 富友要求按上海时区回传原交易日期。 +var fuiouShanghaiLocation = time.FixedZone("CST", 8*3600) + +// RefundAdapter 按冻结商户的服务商类型分派渠道原路退款与退款查询。 +// +// 本适配器只做渠道原始调用与结果映射:不生成请求号、不写数据库、不写审计。 +// 渠道适配按官方契约实现,且不向任何渠道传递退款结果通知地址——退款终态只由 +// 同步响应与主动退款查询确认。 +type RefundAdapter struct { + cache kernel.CacheInterface + logger *zap.Logger +} + +// NewRefundAdapter 创建渠道原路退款适配器。 +func NewRefundAdapter(cache kernel.CacheInterface, logger *zap.Logger) *RefundAdapter { + return &RefundAdapter{cache: cache, logger: logger} +} + +var _ refundchannel.Refunder = (*RefundAdapter)(nil) + +// Refund 按服务商类型提交一次渠道退款请求。 +// 凭证取自 Target.Config(冻结商户的当前凭证),只用于构造渠道客户端,绝不记录或持久化。 +// 渠道明确表态时返回结果且不报错;传输或解析失败返回 error,由调用方按结果未知处理。 +func (a *RefundAdapter) Refund(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { + switch target.ProviderType { + case model.ProviderTypeWechat: + return a.refundWechatV3(ctx, target) + case model.ProviderTypeWechatV2: + return a.refundWechatV2(ctx, target) + case model.ProviderTypeFuiou: + return a.refundFuiou(target) + case "alipay": + return a.refundAlipay(ctx, target) + default: + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: constants.RefundFailureCredentialInvalid, + FailureMessage: "该商户支付渠道的退款凭证不完整,无法执行原路退款", + }, nil + } +} + +// Query 按服务商类型查询渠道退款状态,绝不发起资金动作。 +func (a *RefundAdapter) Query(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { + switch target.ProviderType { + case model.ProviderTypeWechat: + service, err := a.wechatService(target.Config) + if err != nil { + return refundchannel.Result{}, err + } + result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo) + if err != nil { + return refundchannel.Result{}, err + } + return mapWechatRefundResult(result), nil + case model.ProviderTypeWechatV2: + service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger) + if err != nil { + return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款查询配置不可用") + } + result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo) + if err != nil { + return refundchannel.Result{}, err + } + return mapWechatV2RefundResult(result, target.RefundAmount), nil + case model.ProviderTypeFuiou: + client, err := newFuiouClient(target.Config, a.logger) + if err != nil { + return refundchannel.Result{}, err + } + resp, err := client.RefundQuery(target.ChannelRefundRequestNo) + if err != nil { + return refundchannel.Result{}, err + } + return mapFuiouRefundQuery(resp), nil + case "alipay": + result, err := alipay.QueryRefund(ctx, target.Config, alipay.RefundRequest{ + OutTradeNo: target.PaymentNo, + TradeNo: target.ChannelTradeNo, + OutRequestNo: target.ChannelRefundRequestNo, + RefundAmount: target.RefundAmount, + }) + if err != nil { + return refundchannel.Result{}, err + } + return mapAlipayRefundResult(result, target.RefundAmount), nil + default: + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: constants.RefundFailureCredentialInvalid, + FailureMessage: "该商户支付渠道的退款凭证不完整,无法查询退款结果", + }, nil + } +} + +// refundWechatV3 执行微信支付 v3 原路退款。 +func (a *RefundAdapter) refundWechatV3(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { + service, err := a.wechatService(target.Config) + if err != nil { + return refundchannel.Result{}, err + } + result, err := service.RefundOrder(ctx, wechatpay.RefundOrderRequest{ + OutTradeNo: target.PaymentNo, + OutRefundNo: target.ChannelRefundRequestNo, + Amount: target.FrozenActualReceivedAmount, + Refund: target.RefundAmount, + }) + if err != nil { + return refundchannel.Result{}, err + } + return mapWechatRefundResult(result), nil +} + +// wechatService 用商户当前凭证构造微信 v3 支付服务。 +func (a *RefundAdapter) wechatService(config *model.WechatConfig) (*wechatpay.PaymentService, error) { + if config == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用") + } + app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) + if err != nil { + return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信支付退款配置不可用") + } + return wechatpay.NewPaymentService(app, a.logger), nil +} + +// refundFuiou 执行富友原路退款。 +// 回传原交易日期才能覆盖 30 天以上的原交易;不回传仅支持 30 天内,因此始终回传。 +func (a *RefundAdapter) refundFuiou(target refundchannel.Target) (refundchannel.Result, error) { + client, err := newFuiouClient(target.Config, a.logger) + if err != nil { + return refundchannel.Result{}, err + } + origiDt := "" + if target.PaidAt != nil { + origiDt = target.PaidAt.In(fuiouShanghaiLocation).Format("20060102") + } + resp, err := client.CommonRefund(&fuiou.CommonRefundRequest{ + OrderType: target.ChannelOrderType, + MchntOrderNo: target.PaymentNo, + RefundOrderNo: target.ChannelRefundRequestNo, + TotalAmt: strconv.FormatInt(target.FrozenActualReceivedAmount, 10), + RefundAmt: strconv.FormatInt(target.RefundAmount, 10), + ReservedOrigiDt: origiDt, + }) + if err != nil { + return refundchannel.Result{}, err + } + return mapFuiouRefundResponse(resp), nil +} + +// refundAlipay 执行支付宝原路退款。 +func (a *RefundAdapter) refundAlipay(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { + result, err := alipay.Refund(ctx, target.Config, alipay.RefundRequest{ + OutTradeNo: target.PaymentNo, + TradeNo: target.ChannelTradeNo, + OutRequestNo: target.ChannelRefundRequestNo, + RefundAmount: target.RefundAmount, + }) + if err != nil { + return refundchannel.Result{}, err + } + return mapAlipayRefundResult(result, target.RefundAmount), nil +} + +// refundWechatV2 执行微信支付 v2 原路退款(双向证书接口)。 +// +// 渠道契约规定申请接口的返回仅代表受理情况,退款是否成功必须由退款查询确认, +// 因此受理成功在此按「结果未知」返回,由恢复任务查询收敛;渠道明确拒绝时按失败分类返回。 +func (a *RefundAdapter) refundWechatV2(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { + service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger) + if err != nil { + return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款配置不可用") + } + result, err := service.RefundOrderV2(ctx, wechatpay.V2RefundRequest{ + OutTradeNo: target.PaymentNo, + OutRefundNo: target.ChannelRefundRequestNo, + TotalFee: target.FrozenActualReceivedAmount, + RefundFee: target.RefundAmount, + }) + if err != nil { + return refundchannel.Result{}, err + } + return mapWechatV2RefundResult(result, target.RefundAmount), nil +} + +// mapWechatV2RefundResult 映射微信 v2 退款申请或退款查询结果。 +// Accepted 为真表示渠道已受理但结果待查询确认,按结果未知处理;受理接口不返回退款状态。 +func mapWechatV2RefundResult(result *wechatpay.V2RefundResult, requestedAmount int64) refundchannel.Result { + if result == nil { + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "渠道返回空响应,退款结果未知", + } + } + if result.Success { + amount := result.RefundFee + if amount <= 0 { + amount = requestedAmount + } + return refundchannel.Result{ + State: refundchannel.StateSuccess, + ChannelRefundNo: result.RefundID, + ChannelRefundAmount: amount, + } + } + if result.Accepted { + // 渠道已受理:终态必须由退款查询确认,不得在此标记成功。 + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "微信 v2 已受理退款申请,等待退款查询确认结果", + } + } + if result.ErrCode != "" { + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: classifyWechatRejection(result.ErrCode), + FailureMessage: safeChannelMessage(result.ErrCode + " " + result.Message), + } + } + switch strings.ToUpper(strings.TrimSpace(result.Status)) { + case "REFUNDCLOSE", "CHANGE": + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: constants.RefundFailureChannelRejected, + FailureMessage: safeChannelMessage(result.Message), + } + default: + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: safeChannelMessage(result.Message), + } + } +} + +// mapWechatRefundResult 映射微信 v3 退款结果。 +// 渠道业务错误码表示退款单未被受理,按错误码分类;处理中或状态为空表示渠道尚未给出终态, +// 保持结果未知交由恢复任务查询。 +func mapWechatRefundResult(result *wechatpay.RefundOrderResult) refundchannel.Result { + if result == nil { + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "渠道返回空响应,退款结果未知", + } + } + if result.Success { + return refundchannel.Result{ + State: refundchannel.StateSuccess, + ChannelRefundNo: result.RefundID, + ChannelRefundAmount: result.RefundFee, + } + } + if result.ChannelCode != "" { + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: classifyWechatRejection(result.ChannelCode), + FailureMessage: safeChannelMessage(result.Message), + } + } + switch strings.ToUpper(strings.TrimSpace(result.Status)) { + case "CLOSED", "ABNORMAL": + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: constants.RefundFailureChannelRejected, + FailureMessage: safeChannelMessage(result.Message), + } + default: + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: safeChannelMessage(result.Message), + } + } +} + +// classifyWechatRejection 把微信拒绝类错误码映射为稳定失败分类。 +// 系统异常与限频不代表渠道已明确拒绝,保持结果未知交恢复任务查询。 +func classifyWechatRejection(code string) string { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "SYSTEM_ERROR", "FREQUENCY_LIMITED", "RATELIMIT_EXCEED": + return constants.RefundFailureTimeoutUnknown + case "NOT_ENOUGH", "BALANCE_NOT_ENOUGH": + return constants.RefundFailureInsufficientBalance + case "NO_AUTH", "SIGN_ERROR": + return constants.RefundFailureCredentialInvalid + default: + return constants.RefundFailureChannelRejected + } +} + +// mapFuiouRefundResponse 映射富友退款申请响应。 +func mapFuiouRefundResponse(resp *fuiou.CommonRefundResponse) refundchannel.Result { + if resp == nil { + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "渠道返回空响应,退款结果未知", + } + } + if resp.ResultCode != fuiou.ResultCodeSuccess { + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: classifyFuiouRejection(resp.ResultMsg), + FailureMessage: safeChannelMessage(resp.ResultMsg), + } + } + return refundchannel.Result{ + State: refundchannel.StateSuccess, + ChannelRefundNo: resp.RefundId, + ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt), + SettledAt: resp.ReservedFySettleDt, + } +} + +// mapFuiouRefundQuery 映射富友退款查询响应。 +// trans_stat 只取 SUCCESS 或 PAYERROR:PAYERROR 视为渠道明确失败,其余保持未知。 +func mapFuiouRefundQuery(resp *fuiou.RefundQueryResponse) refundchannel.Result { + if resp == nil { + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "渠道返回空响应,退款结果未知", + } + } + if resp.ResultCode != fuiou.ResultCodeSuccess { + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: classifyFuiouRejection(resp.ResultMsg), + FailureMessage: safeChannelMessage(resp.ResultMsg), + } + } + switch strings.ToUpper(strings.TrimSpace(resp.TransStat)) { + case fuiou.TransStatSuccess: + return refundchannel.Result{ + State: refundchannel.StateSuccess, + ChannelRefundNo: resp.RefundId, + ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt), + SettledAt: resp.ReservedFySettleDt, + } + case fuiou.TransStatPayError: + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: constants.RefundFailureChannelRejected, + FailureMessage: "富友退款交易状态为失败", + } + default: + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "富友退款交易仍在办理中", + } + } +} + +// classifyFuiouRejection 按富友错误文案粗分类;无法识别时归为渠道明确拒绝。 +func classifyFuiouRejection(message string) string { + text := strings.TrimSpace(message) + switch { + case strings.Contains(text, "余额") || strings.Contains(text, "不足"): + return constants.RefundFailureInsufficientBalance + case strings.Contains(text, "签名") || strings.Contains(text, "验签") || strings.Contains(text, "密钥"): + return constants.RefundFailureCredentialInvalid + default: + return constants.RefundFailureChannelRejected + } +} + +// mapAlipayRefundResult 映射支付宝退款或退款查询结果。 +func mapAlipayRefundResult(result *alipay.RefundResult, requestedAmount int64) refundchannel.Result { + if result == nil { + return refundchannel.Result{ + State: refundchannel.StateUnknown, + FailureReason: constants.RefundFailureTimeoutUnknown, + FailureMessage: "渠道返回空响应,退款结果未知", + } + } + if result.Success { + amount := result.RefundFee + if amount == 0 { + amount = requestedAmount + } + return refundchannel.Result{ + State: refundchannel.StateSuccess, + ChannelRefundNo: result.TradeNo, + ChannelRefundAmount: amount, + } + } + return refundchannel.Result{ + State: refundchannel.StateFailed, + FailureReason: classifyAlipayRejection(result.Message), + FailureMessage: safeChannelMessage(result.Message), + } +} + +// classifyAlipayRejection 按支付宝错误码粗分类;无法识别时归为渠道明确拒绝。 +func classifyAlipayRejection(message string) string { + text := strings.ToUpper(strings.TrimSpace(message)) + switch { + case strings.Contains(text, "BALANCE_NOT_ENOUGH") || strings.Contains(text, "余额不足"): + return constants.RefundFailureInsufficientBalance + case strings.Contains(text, "SIGN") || strings.Contains(text, "AUTH"): + return constants.RefundFailureCredentialInvalid + case strings.Contains(text, "SYSTEM_ERROR"): + return constants.RefundFailureTimeoutUnknown + default: + return constants.RefundFailureChannelRejected + } +} + +// safeChannelMessage 截断渠道文案并去掉换行,避免把渠道报文原文写入失败摘要。 +func safeChannelMessage(message string) string { + text := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(message, "\n", " "), "\r", " ")) + if len(text) > 200 { + return text[:200] + } + return text +} + +// parseFuiouAmount 把富友返回的金额字符串精确转换为分;空值或不可解析时按 0 处理。 +func parseFuiouAmount(amount string) int64 { + text := strings.TrimSpace(amount) + if text == "" { + return 0 + } + value, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return 0 + } + return value +} + +// newFuiouClient 用商户当前凭证构造富友客户端。 +func newFuiouClient(config *model.WechatConfig, logger *zap.Logger) (*fuiou.Client, error) { + if config == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用") + } + return fuiou.NewClient(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL, + config.FyNotifyURL, config.FyPrivateKey, config.FyPublicKey, logger) +} diff --git a/internal/infrastructure/payment/refund_channel_recovery_task.go b/internal/infrastructure/payment/refund_channel_recovery_task.go new file mode 100644 index 0000000..f11b7ce --- /dev/null +++ b/internal/infrastructure/payment/refund_channel_recovery_task.go @@ -0,0 +1,39 @@ +package payment + +import ( + "context" + + "github.com/hibiken/asynq" + + refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RefundChannelRecoveryTaskHandler 执行渠道原路退款结果恢复任务。 +type RefundChannelRecoveryTaskHandler struct { + service *refundchannel.Service +} + +// NewRefundChannelRecoveryTaskHandler 创建渠道原路退款结果恢复任务 Handler。 +func NewRefundChannelRecoveryTaskHandler(service *refundchannel.Service) *RefundChannelRecoveryTaskHandler { + return &RefundChannelRecoveryTaskHandler{service: service} +} + +// Handle 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。 +func (h *RefundChannelRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "渠道原路退款恢复任务未配置") + } + taskType := constants.TaskTypeRefundChannelRecovery + if task != nil && task.Type() != "" { + taskType = task.Type() + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: taskType, + ActorName: "渠道原路退款结果恢复计划任务", Source: constants.AuditSourceScheduler, + }) + _, err := h.service.ProcessBatch(ctx) + return err +} diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index 2b94de5..c1208ac 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -3,9 +3,11 @@ package dto // CreateRefundRequest 创建退款申请请求 type CreateRefundRequest struct { OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"` - ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"` + ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"` + Method string `json:"method" validate:"required,oneof=original_route customer_account asset_wallet agent_wallet" required:"true" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"` + CustomerAccountInfo string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"` RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"` - RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` + RefundVoucherKey []string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(最多5个,仅客户收款信息退款方式必填,通过/storage/upload-url上传图片后获得)"` RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"` } @@ -25,9 +27,11 @@ type RejectRefundRequest struct { // 退款单被退回后,可修改部分字段后重新提交 type ResubmitRefundRequest struct { ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"` - ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"` + ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"已废弃:实收金额由系统从原成功支付记录或订单实际收款派生并冻结,提交人填写无效"` + Method *string `json:"method" validate:"omitempty,oneof=original_route customer_account asset_wallet agent_wallet" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),不填沿用原有方式"` + CustomerAccountInfo *string `json:"customer_account_info" validate:"omitempty,max=1000" maxLength:"1000" description:"客户收款信息,仅客户收款信息退款方式必填,不得复用公司线下收款方式字典"` RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"` - RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"` + RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;客户收款信息退款方式必填,最多5个)"` RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` } @@ -48,7 +52,7 @@ type ReturnRefundRequest struct { type RefundListRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=6" minimum:"1" maximum:"6" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"` OrderID *uint `json:"order_id" query:"order_id" validate:"omitempty" description:"关联订单ID"` ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"` AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识精确检索(ICCID 或 设备虚拟号,非空时精确匹配)"` @@ -56,40 +60,58 @@ type RefundListRequest struct { // RefundResponse 退款申请详情响应 type RefundResponse struct { - ID uint `json:"id" description:"退款申请ID"` - RefundNo string `json:"refund_no" description:"退款单号"` - OrderID uint `json:"order_id" description:"关联订单ID"` - OrderNo string `json:"order_no" description:"订单号"` - AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` - AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` - IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` - DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` - PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` - ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` - ShopName string `json:"shop_name,omitempty" description:"店铺名称"` - ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` - RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"` - ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` - RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"` - RefundReason string `json:"refund_reason" description:"退款原因"` - Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"` - ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"` - RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"` - Remark string `json:"remark,omitempty" description:"审批备注"` - CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"` - AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"` - SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` - SubmitterName string `json:"submitter_name" description:"提交人账号名称"` - ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"` - 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:"审批状态名称(中文)"` - Creator uint `json:"creator" description:"创建人ID"` - Updater uint `json:"updater" description:"更新人ID"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"退款申请ID"` + RefundNo string `json:"refund_no" description:"退款单号"` + OrderID uint `json:"order_id" description:"关联订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` + AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` + IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` + DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` + PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` + ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` + ShopName string `json:"shop_name,omitempty" description:"店铺名称"` + ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` + RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"` + ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` + RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"` + RefundReason string `json:"refund_reason" description:"退款原因"` + Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回, 5:原路退款处理中, 6:原路退款失败)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + Method string `json:"method" description:"退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包),空表示未接入方式的存量申请"` + MethodName string `json:"method_name" description:"退款方式中文名称"` + FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"系统派生并冻结的权威实收金额(分),作为可退金额上限"` + CustomerAccountInfo string `json:"customer_account_info" description:"客户收款信息自由文本快照,仅客户收款信息退款方式有值"` + ChannelRefundStatus int `json:"channel_refund_status" description:"渠道原路退款状态 (0:未发起, 1:处理中, 2:已成功, 3:已失败),0 表示未发起渠道退款或不适用该退款方式"` + ChannelRefundStatusName string `json:"channel_refund_status_name" description:"渠道原路退款状态中文名称"` + ChannelRefundNo string `json:"channel_refund_no" description:"渠道退款流水号,渠道明确成功或失败后回填"` + ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"渠道退款请求号快照,用于幂等与对账"` + ChannelRefundAmount int64 `json:"channel_refund_amount" description:"提交渠道的退款金额快照(分)"` + ChannelRefundedAt string `json:"channel_refunded_at,omitempty" description:"渠道明确退款成功时间"` + FailureReason string `json:"failure_reason" description:"结构化失败分类稳定编码 (channel_rejected:渠道明确拒绝, credential_invalid:渠道凭证失效, insufficient_balance:渠道余额不足, timeout_unknown:超时或结果未知, approval_rejected:企业微信驳回或关闭, revoked_after_approved:企业微信通过后撤销, payment_fact_invalid:本地原支付事实不可用),空表示无失败"` + FailureReasonName string `json:"failure_reason_name" description:"失败分类中文名称"` + FailureMessage string `json:"failure_message" description:"失败安全摘要,供人工排查;不含渠道凭证等敏感内容"` + AnomalyFlag int `json:"anomaly_flag" description:"异常标记 (0:无异常, 1:有异常,需人工处理)"` + AnomalyReason string `json:"anomaly_reason" description:"异常原因说明,无异常时为空"` + LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,仅用于展示"` + LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,仅用于展示"` + Attempts []RefundAttemptResponse `json:"attempts" description:"审批尝试记录,按提交顺序排列,历史材料不被覆盖;无尝试记录时为空数组"` + ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"` + ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"` + RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"` + Remark string `json:"remark,omitempty" description:"审批备注"` + CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"` + AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"` + 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:"审批状态名称(中文)"` + Creator uint `json:"creator" description:"创建人ID"` + Updater uint `json:"updater" description:"更新人ID"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // RefundListResponse 退款申请列表分页响应 @@ -99,3 +121,22 @@ type RefundListResponse struct { Page int `json:"page" description:"当前页码"` Size int `json:"size" description:"每页数量"` } + +// RefundAttemptResponse 退款审批尝试响应,材料为本次提交的冻结快照。 +type RefundAttemptResponse struct { + ID uint `json:"id" description:"审批尝试记录ID,同时是通用审批业务ID"` + AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"` + Method string `json:"method" description:"本次冻结的退款方式 (original_route:原路退款, customer_account:客户收款信息退款, asset_wallet:退回资产钱包, agent_wallet:退回代理主钱包)"` + MethodName string `json:"method_name" description:"本次冻结的退款方式中文名称"` + RefundAmount int64 `json:"refund_amount" description:"本次提交冻结的申请退款金额(分)"` + FrozenActualReceivedAmount int64 `json:"frozen_actual_received_amount" description:"本次提交冻结的权威实收金额(分)"` + RefundReason string `json:"refund_reason" description:"本次提交的退款原因"` + CustomerAccountInfo string `json:"customer_account_info" description:"本次冻结的客户收款信息快照,非客户收款信息退款方式为空"` + CustomerVoucherKey []string `json:"customer_voucher_key" description:"客户收款凭证对象存储file_key列表,仅返回对象键引用"` + ChannelRefundRequestNo string `json:"channel_refund_request_no" description:"本次提交使用的渠道退款请求号快照,用于幂等与对账"` + SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID,0 表示未关联"` + ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"` + CreatedAt string `json:"created_at" description:"创建时间"` +} diff --git a/internal/model/dto/wechat_config_dto.go b/internal/model/dto/wechat_config_dto.go index de584b9..c6bab45 100644 --- a/internal/model/dto/wechat_config_dto.go +++ b/internal/model/dto/wechat_config_dto.go @@ -28,6 +28,9 @@ type CreateWechatConfigRequest struct { WxKeyContent string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"` WxSerialNo string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"` WxNotifyURL string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"` + // v2 退款接口为双向证书接口,支付与查单不依赖该证书。 + WxClientCertContent string `json:"wx_client_cert_content" validate:"omitempty" description:"微信支付API客户端证书内容(PEM格式,v2 退款双向证书所需)"` + WxClientKeyContent string `json:"wx_client_key_content" validate:"omitempty" description:"微信支付API客户端证书私钥内容(PEM格式,v2 退款双向证书所需)"` FyInsCd string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"` FyMchntCd string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"` @@ -69,6 +72,9 @@ type UpdateWechatConfigRequest struct { WxKeyContent *string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"` WxSerialNo *string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"` WxNotifyURL *string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"` + // v2 退款接口为双向证书接口,支付与查单不依赖该证书。 + WxClientCertContent *string `json:"wx_client_cert_content" validate:"omitempty" description:"微信支付API客户端证书内容(PEM格式,v2 退款双向证书所需)"` + WxClientKeyContent *string `json:"wx_client_key_content" validate:"omitempty" description:"微信支付API客户端证书私钥内容(PEM格式,v2 退款双向证书所需)"` FyInsCd *string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"` FyMchntCd *string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"` @@ -119,13 +125,15 @@ type WechatConfigResponse struct { MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"` MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret(已脱敏)"` - WxMchID string `json:"wx_mch_id" description:"微信商户号"` - WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"` - WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"` - WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"` - WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"` - WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"` - WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"` + WxMchID string `json:"wx_mch_id" description:"微信商户号"` + WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"` + WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"` + WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"` + WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"` + WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"` + WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"` + WxClientCertContent string `json:"wx_client_cert_content" description:"微信支付API客户端证书内容(配置状态,v2 退款双向证书所需)"` + WxClientKeyContent string `json:"wx_client_key_content" description:"微信支付API客户端证书私钥内容(配置状态,v2 退款双向证书所需)"` FyInsCd string `json:"fy_ins_cd" description:"富友机构号"` FyMchntCd string `json:"fy_mchnt_cd" description:"富友商户号"` diff --git a/internal/model/refund.go b/internal/model/refund.go index 50153db..696daf3 100644 --- a/internal/model/refund.go +++ b/internal/model/refund.go @@ -3,6 +3,7 @@ package model import ( "time" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -42,6 +43,30 @@ type RefundRequest struct { RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"` Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"` + // 退款方式、冻结实收与当前材料快照 + Method string `gorm:"column:method;type:varchar(20);not null;default:'';comment:退款方式,空表示未接入方式的存量申请" json:"method"` + FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null;default:0;comment:冻结的权威实收金额(分)" json:"frozen_actual_received_amount"` + CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:'';comment:客户收款信息自由文本快照" json:"customer_account_info"` + + // 审批尝试引用,仅用于列表与详情展示 + LatestAttemptID uint `gorm:"column:latest_attempt_id;type:bigint;not null;default:0;comment:最新审批尝试记录ID,仅用于展示" json:"latest_attempt_id"` + LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;type:bigint;not null;default:0;comment:最新通用审批实例ID,仅用于展示" json:"latest_approval_instance_id"` + + // 渠道原路退款结果与结构化失败分类 + ChannelRefundStatus int `gorm:"column:channel_refund_status;type:smallint;not null;default:0;comment:渠道退款状态 0-未发起或不适用 1-处理中 2-明确成功 3-明确失败" json:"channel_refund_status"` + ChannelRefundNo string `gorm:"column:channel_refund_no;type:varchar(64);not null;default:'';comment:渠道退款流水号" json:"channel_refund_no"` + ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:'';comment:渠道退款请求号快照" json:"channel_refund_request_no"` + ChannelRefundAmount int64 `gorm:"column:channel_refund_amount;type:bigint;not null;default:0;comment:渠道退款金额快照(分)" json:"channel_refund_amount"` + ChannelRefundedAt *time.Time `gorm:"column:channel_refunded_at;comment:渠道明确退款成功时间" json:"channel_refunded_at,omitempty"` + // ChannelSubmittedAt 非空表示该尝试已向渠道提交过退款请求;重投只允许查询,不得再次提交。 + ChannelSubmittedAt *time.Time `gorm:"column:channel_submitted_at;comment:渠道退款请求提交认领时间" json:"channel_submitted_at,omitempty"` + FailureReason string `gorm:"column:failure_reason;type:varchar(32);not null;default:'';comment:结构化失败分类稳定编码" json:"failure_reason"` + FailureMessage string `gorm:"column:failure_message;type:varchar(500);not null;default:'';comment:失败安全摘要" json:"failure_message"` + + // 正交异常标记:企业微信通过后撤销、渠道结果永久未知等情况转人工处理 + AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0;comment:异常标记 0-无异常 1-有异常" json:"anomaly_flag"` + AnomalyReason string `gorm:"column:anomaly_reason;type:varchar(500);not null;default:'';comment:异常原因" json:"anomaly_reason"` + // 后处理标记 CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"` AssetReset bool `gorm:"column:asset_reset;not null;default:false;comment:退款后资产处理是否完成" json:"asset_reset"` @@ -52,10 +77,51 @@ func (RefundRequest) TableName() string { return "tb_refund_request" } +// RefundRequestAttempt 退款审批尝试记录。 +// 每次提交或重提新增一条不可变记录,冻结当次方式、金额、冻结实收、原因、客户收款信息、 +// 凭证与套餐使用快照;主键同时作为通用审批业务标识,使同一退款单每次提交持有独立审批实例。 +type RefundRequestAttempt struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + RefundID uint `gorm:"column:refund_id;not null;index" json:"refund_id"` + AttemptNo int `gorm:"column:attempt_no;type:int;not null" json:"attempt_no"` + Method string `gorm:"column:method;type:varchar(20);not null" json:"method"` + RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null" json:"refund_amount"` + FrozenActualReceivedAmount int64 `gorm:"column:frozen_actual_received_amount;type:bigint;not null" json:"frozen_actual_received_amount"` + RefundReason string `gorm:"column:refund_reason;type:text;not null;default:''" json:"refund_reason"` + CustomerAccountInfo string `gorm:"column:customer_account_info;type:text;not null;default:''" json:"customer_account_info"` + CustomerVoucherKeys StringJSONBArray `gorm:"column:customer_voucher_keys;type:jsonb;not null" json:"customer_voucher_keys"` + PackageUsageSnapshot datatypes.JSON `gorm:"column:package_usage_snapshot;type:jsonb;not null" json:"package_usage_snapshot"` + ChannelRefundRequestNo string `gorm:"column:channel_refund_request_no;type:varchar(64);not null;default:''" json:"channel_refund_request_no"` + SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null" json:"submitted_by_account_id"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id" json:"approval_instance_id,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` +} + +// TableName 指定审批尝试记录表名。 +func (RefundRequestAttempt) TableName() string { + return "tb_refund_request_attempt" +} + // 退款状态常量 const ( RefundStatusPending = 1 // 待审批 - RefundStatusApproved = 2 // 已通过 + RefundStatusApproved = 2 // 已通过(退款已完成) RefundStatusRejected = 3 // 已拒绝 RefundStatusReturned = 4 // 已退回 + + // RefundStatusChannelProcessing 表示企业微信已通过、原路渠道结果尚未确认。 + RefundStatusChannelProcessing = 5 + // RefundStatusChannelFailed 表示原路渠道明确失败或超时可恢复失败。 + RefundStatusChannelFailed = 6 ) + +// RefundActiveStatuses 返回会阻止同一订单创建新退款申请的状态集合。 +// 该集合必须与迁移中 uk_refund_request_active_order 的谓词保持一致。 +func RefundActiveStatuses() []int { + return []int{RefundStatusPending, RefundStatusChannelProcessing, RefundStatusChannelFailed} +} + +// RefundResubmittableStatuses 返回允许修改材料并重提的状态集合。 +func RefundResubmittableStatuses() []int { + return []int{RefundStatusRejected, RefundStatusReturned, RefundStatusChannelFailed} +} diff --git a/internal/model/wechat_config.go b/internal/model/wechat_config.go index 5b70dd2..e536486 100644 --- a/internal/model/wechat_config.go +++ b/internal/model/wechat_config.go @@ -43,6 +43,9 @@ type WechatConfig struct { WxKeyContent string `gorm:"column:wx_key_content;type:text;default:'';comment:微信支付密钥内容" json:"wx_key_content"` WxSerialNo string `gorm:"column:wx_serial_no;type:varchar(200);default:'';comment:微信证书序列号" json:"wx_serial_no"` WxNotifyURL string `gorm:"column:wx_notify_url;type:varchar(500);default:'';comment:微信支付回调地址" json:"wx_notify_url"` + // v2 退款接口(/secapi/pay/refund)请求需要双向证书,仅 APIv2 密钥不足以退款。 + WxClientCertContent string `gorm:"column:wx_client_cert_content;type:text;default:'';comment:微信支付API客户端证书内容(apiclient_cert.pem)" json:"wx_client_cert_content"` + WxClientKeyContent string `gorm:"column:wx_client_key_content;type:text;default:'';comment:微信支付API客户端证书私钥内容(apiclient_key.pem)" json:"wx_client_key_content"` // 支付-富友 FyInsCd string `gorm:"column:fy_ins_cd;type:varchar(50);default:'';comment:富友机构号" json:"fy_ins_cd"` diff --git a/internal/query/audit/finance.go b/internal/query/audit/finance.go index e45837b..2df50fa 100644 --- a/internal/query/audit/finance.go +++ b/internal/query/audit/finance.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" + "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" "github.com/break/junhong_cmp_fiber/internal/model" retentionquery "github.com/break/junhong_cmp_fiber/internal/query/retention" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -520,13 +521,17 @@ func (q *Query) expandRecharges(ctx context.Context, refs *financeRefs) error { } // expandApprovals 只按审批业务类型关联退款或线下代理充值。 +// +// 退款审批的业务标识在尝试模式下指向审批尝试记录、存量模式下指向退款申请,两者来自独立自增序列, +// 因此按退款单过滤时必须同时展开「尝试记录指向的退款单」与「退款单自身」两种审批实例, +// 命中后再经共享解析器还原真实退款单,避免把尝试记录主键当作退款单编号收集。 func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error { conditions, args := make([]string, 0, 3), make([]any, 0, 3) if len(refs.approvals) > 0 { conditions, args = append(conditions, "id IN ?"), append(args, uintKeys(refs.approvals)) } if len(refs.refunds) > 0 { - conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds)) + conditions, args = append(conditions, refundApprovalBusinessCondition()), append(args, constants.ApprovalBusinessTypeRefund, uintKeys(refs.refunds), uintKeys(refs.refunds)) } if len(refs.agentRecharges) > 0 { conditions, args = append(conditions, "business_type = ? AND business_id IN ?"), append(args, constants.ApprovalBusinessTypeOfflineRecharge, uintKeys(refs.agentRecharges)) @@ -542,7 +547,11 @@ func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error { addUint(refs.approvals, row.ID) switch row.BusinessType { case constants.ApprovalBusinessTypeRefund: - addUint(refs.refunds, row.BusinessID) + refundID, err := refundapproval.ResolveRefundIDInTx(ctx, q.db, row.BusinessID, row.ID) + if err != nil { + return err + } + addUint(refs.refunds, refundID) case constants.ApprovalBusinessTypeOfflineRecharge: addUint(refs.agentRecharges, row.BusinessID) } @@ -550,6 +559,12 @@ func (q *Query) expandApprovals(ctx context.Context, refs *financeRefs) error { return nil } +// refundApprovalBusinessCondition 返回退款审批实例的过滤条件,覆盖尝试模式与存量模式两种业务标识语义。 +// 三个占位符依次为业务类型、尝试记录主键、退款单主键。 +func refundApprovalBusinessCondition() string { + return "business_type = ? AND (business_id IN ? OR business_id IN (SELECT refund_attempt.id FROM tb_refund_request_attempt AS refund_attempt WHERE refund_attempt.refund_id IN ?))" +} + // loadFinanceAuditRows 只读取具有资金资源的审计事件,并保留操作者权威。 func (q *Query) loadFinanceAuditRows(ctx context.Context, filter FinanceFilter, refs *financeRefs) ([]model.AuditEvent, int64, error) { resourceTypes := []string{ @@ -1133,11 +1148,12 @@ func (q *Query) loadApprovalFinance(ctx context.Context, filter FinanceFilter, r query = applyFinanceTime(query, filter, "status_changed_at") conditions, args := make([]string, 0, 5), make([]any, 0, 5) appendUintCondition(&conditions, &args, "id", refs.approvals) - appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeRefund, refs.refunds) + appendRefundApprovalBusinessCondition(&conditions, &args, refs.refunds) appendApprovalBusinessCondition(&conditions, &args, constants.ApprovalBusinessTypeOfflineRecharge, refs.agentRecharges) if filter.ShopID != 0 { - conditions = append(conditions, `(business_type = ? AND EXISTS (SELECT 1 FROM tb_refund_request r WHERE r.id = tb_approval_instance.business_id AND r.deleted_at IS NULL AND r.shop_id = ?)) OR (business_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_approval_instance.business_id AND ar.deleted_at IS NULL AND ar.shop_id = ?))`) - args = append(args, constants.ApprovalBusinessTypeRefund, filter.ShopID, constants.ApprovalBusinessTypeOfflineRecharge, filter.ShopID) + // 退款审批的店铺归属要同时覆盖尝试模式(business_id 指向尝试记录)与存量模式(business_id 指向退款单)。 + conditions = append(conditions, `(business_type = ? AND (EXISTS (SELECT 1 FROM tb_refund_request_attempt a JOIN tb_refund_request r ON r.id = a.refund_id AND r.deleted_at IS NULL WHERE a.id = tb_approval_instance.business_id AND r.shop_id = ?) OR EXISTS (SELECT 1 FROM tb_refund_request r WHERE r.id = tb_approval_instance.business_id AND r.deleted_at IS NULL AND r.shop_id = ?))) OR (business_type = ? AND EXISTS (SELECT 1 FROM tb_agent_recharge_record ar WHERE ar.id = tb_approval_instance.business_id AND ar.deleted_at IS NULL AND ar.shop_id = ?))`) + args = append(args, constants.ApprovalBusinessTypeRefund, filter.ShopID, filter.ShopID, constants.ApprovalBusinessTypeOfflineRecharge, filter.ShopID) } appendAccountActorCondition(&conditions, &args, filter, "submitter_account_id") if filter.CorrelationID != "" { @@ -1152,7 +1168,11 @@ func (q *Query) loadApprovalFinance(ctx context.Context, filter FinanceFilter, r for _, row := range rows { refsView := ledgerRefs(constants.AuditResourceApprovalInstance, strconv.FormatUint(uint64(row.ID), 10), strconv.FormatUint(uint64(row.ID), 10), row.SubmitterAccountID) refsView.CorrelationID = stringPointer(row.CorrelationID) - refsView.ResourceRefs = append(refsView.ResourceRefs, approvalBusinessRefs(row)...) + businessRefs, err := approvalBusinessRefs(ctx, q.db, row) + if err != nil { + return nil, 0, err + } + refsView.ResourceRefs = append(refsView.ResourceRefs, businessRefs...) nodes = append(nodes, FinanceTimelineNode{ RecordSource: constants.AuditRecordSourceApprovalInstance, NodeID: strconv.FormatUint(uint64(row.ID), 10), OccurredAt: row.StatusChangedAt, Code: row.BusinessType, Title: "审批实例", Result: strconv.Itoa(row.Status), ResultName: constants.GetApprovalStatusName(row.Status), @@ -1259,6 +1279,17 @@ func appendApprovalBusinessCondition(conditions *[]string, args *[]any, business *args = append(*args, businessType, uintKeys(values)) } +// appendRefundApprovalBusinessCondition 关联按退款单过滤的退款审批实例。 +// 业务标识在尝试模式下指向审批尝试记录、存量模式下指向退款申请,因此两种语义都要命中。 +func appendRefundApprovalBusinessCondition(conditions *[]string, args *[]any, values map[uint]struct{}) { + if len(values) == 0 { + return + } + refundIDs := uintKeys(values) + *conditions = append(*conditions, refundApprovalBusinessCondition()) + *args = append(*args, constants.ApprovalBusinessTypeRefund, refundIDs, refundIDs) +} + func appendAccountActorCondition(conditions *[]string, args *[]any, filter FinanceFilter, columns ...string) { if filter.ActorKind != constants.AuditActorAccount || filter.ActorID == "" { return @@ -1354,18 +1385,27 @@ func paymentBusinessRefs(row model.Payment) []InvestigationResourceRef { } // approvalBusinessRefs 按审批实例声明的业务类型生成稳定跳转。 -func approvalBusinessRefs(row model.ApprovalInstance) []InvestigationResourceRef { +// +// 退款审批的业务标识在尝试模式下指向审批尝试记录,直接用 business_id 跳转会指向错误资源, +// 因此这里经共享解析器还原真实退款单;存量模式(business_id 即退款单主键)由解析器兜底命中。 +func approvalBusinessRefs(ctx context.Context, db *gorm.DB, row model.ApprovalInstance) ([]InvestigationResourceRef, error) { resourceType := "" + resourceID := row.BusinessID switch row.BusinessType { case constants.ApprovalBusinessTypeRefund: resourceType = constants.AuditResourceRefund + refundID, err := refundapproval.ResolveRefundIDInTx(ctx, db, row.BusinessID, row.ID) + if err != nil { + return nil, err + } + resourceID = refundID case constants.ApprovalBusinessTypeOfflineRecharge: resourceType = constants.AuditResourceAgentRecharge } if resourceType == "" { - return nil + return nil, nil } - return []InvestigationResourceRef{financeResourceRef(resourceType, row.BusinessID, "")} + return []InvestigationResourceRef{financeResourceRef(resourceType, resourceID, "")}, nil } func authoritativeAmount(table, field string) FinanceAmountAuthority { diff --git a/internal/routes/payment_merchant.go b/internal/routes/payment_merchant.go index 2386589..61f7e0a 100644 --- a/internal/routes/payment_merchant.go +++ b/internal/routes/payment_merchant.go @@ -30,8 +30,9 @@ const paymentMerchantCredentialDoc = `商户凭证 credentials 为扁平 JSON - payment_method=wechat、provider_type=wechat_v2:wx_mch_id、wx_api_v2_key、wx_notify_url - payment_method=wechat、provider_type=fuiou:fy_mchnt_cd、fy_ins_cd、fy_term_id、fy_private_key、fy_public_key、fy_api_url、fy_notify_url - payment_method=alipay、provider_type=alipay:ali_app_id、ali_private_key、ali_public_key、ali_notify_url、ali_return_url -可选键:微信商户可附 wx_api_v2_key;支付宝商户可附 ali_production(布尔,是否生产环境)与 ali_pay_expire_minutes(整数,支付过期分钟数)。 +可选键:微信商户可附 wx_api_v2_key;微信 v2 商户可附 wx_client_cert_content、wx_client_key_content(API 客户端证书与私钥,内容为 PEM 文本);支付宝商户可附 ali_production(布尔,是否生产环境)与 ali_pay_expire_minutes(整数,支付过期分钟数)。 除 ali_production 与 ali_pay_expire_minutes 外,凭证值必须为字符串;merchant_identity 必须分别等于 wx_mch_id、fy_mchnt_cd 或 ali_app_id。 +微信 v2 商户的支付与查单只需要 wx_api_v2_key;原路退款接口为双向证书接口,缺少 wx_client_cert_content 与 wx_client_key_content 时该商户的原路退款按凭证不完整判定为不可用,补录后即可用。 商户凭证为敏感信息,仅超级管理员与平台用户可读可写,日志、审计与支付快照不保存凭证内容。` func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMerchantHandler, doc *openapi.Generator, basePath string) { diff --git a/internal/routes/refund.go b/internal/routes/refund.go index 76749bb..7a4169a 100644 --- a/internal/routes/refund.go +++ b/internal/routes/refund.go @@ -26,11 +26,12 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc groupPath := basePath + "/refunds" Register(refund, doc, groupPath, "POST", "", handler.Create, RouteSpec{ - Summary: "创建退款申请", - Tags: []string{"退款管理"}, - Input: new(dto.CreateRefundRequest), - Output: new(dto.RefundResponse), - Auth: true, + Summary: "创建退款申请", + Description: "实收金额由系统从原成功支付记录或订单实际收款派生并冻结,请求体中的 actual_received_amount 已废弃并被忽略。退款方式必填:原路退款、退回资产钱包、退回代理主钱包按各自资金路径执行,客户收款信息退款需同时提供 customer_account_info 与退款凭证;非客户收款信息方式的客户收款信息与凭证不参与校验。", + Tags: []string{"退款管理"}, + Input: new(dto.CreateRefundRequest), + Output: new(dto.RefundResponse), + Auth: true, }) Register(refund, doc, groupPath, "GET", "", handler.List, RouteSpec{ @@ -82,10 +83,11 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc }) Register(refund, doc, groupPath, "POST", "/:id/resubmit", handler.Resubmit, RouteSpec{ - Summary: "重新提交退款申请", - Tags: []string{"退款管理"}, - Input: new(dto.ResubmitRefundRequest), - Output: nil, - Auth: true, + Summary: "重新提交退款申请", + Description: "仅已拒绝、已退回或原路退款失败且无审批异常的退款申请可重提;重提时实收金额仍由系统重新派生冻结并忽略请求中的 actual_received_amount,每次重提新增一条审批尝试记录并创建新的企业微信审批实例,历史尝试材料与审批结果不被覆盖。", + Tags: []string{"退款管理"}, + Input: new(dto.ResubmitRefundRequest), + Output: nil, + Auth: true, }) } diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 235ff84..3ba370c 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -10,6 +10,7 @@ import ( approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" + refundapproval "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/model" @@ -30,28 +31,76 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted: return s.applyClosedDecision(ctx, event) case constants.ApprovalDecisionRevokedAfterApproved: - return nil + return s.applyRevokedAfterApproved(ctx, event) default: return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态") } } +// applyRevokedAfterApproved 处理企业微信通过后撤销。 +// +// 不回滚已失效的套餐权益、不取消已提交的渠道退款、也不恢复订单退款状态:这些事实在通过时 +// 已经成立。系统只标记审批异常并禁止后续自动重提,交由超级管理员线下处理。 +func (s *Service) applyRevokedAfterApproved(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) + var refund model.RefundRequest + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + target, _, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") + } + if refund.AnomalyFlag == 1 { + return nil + } + beforeRefund := refundAuditState(&refund) + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND anomaly_flag = 0", refund.ID). + Updates(map[string]any{ + "anomaly_flag": 1, + "anomaly_reason": "企业微信通过后撤销,需超级管理员线下处理", + "failure_reason": constants.RefundFailureRevokedAfterApproved, + "updated_at": event.OccurredAt, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记退款审批异常失败") + } + if result.RowsAffected != 1 { + return nil + } + refund.AnomalyFlag = 1 + refund.AnomalyReason = "企业微信通过后撤销,需超级管理员线下处理" + refund.FailureReason = constants.RefundFailureRevokedAfterApproved + return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundAnomalyFlagged, "标记退款审批异常", + "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":revoked", beforeRefund, nil, "退款审批通过后撤销") + }) + if err != nil { + return err + } + return nil +} + func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) var refund model.RefundRequest var order model.Order changed := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { + // 业务标识解析:审批尝试记录优先,退款申请兜底(兼容尚未接入尝试模式的存量申请)。 + target, attempt, err := refundapproval.ResolveRefundInTx(ctx, tx, event.BusinessID, event.InstanceID) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&refund, target.ID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } - if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID { - return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致") - } - if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved { + if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved && + refund.Status != model.RefundStatusChannelProcessing { return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过") } if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil { @@ -66,13 +115,21 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil { return err } + // 原路退款只登记待执行事实:退款单转入原路处理中,订单在渠道明确成功前保持已支付。 + // 客户收款信息与退回原钱包在审批通过时即完成,订单同时置为已退款。 + originalRoute := refund.Method == constants.RefundMethodOriginalRoute if refund.Status == model.RefundStatusPending { changed = true + targetStatus := model.RefundStatusApproved + if originalRoute { + targetStatus = model.RefundStatusChannelProcessing + } result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ - "status": model.RefundStatusApproved, "processed_at": event.OccurredAt, + "status": targetStatus, "processed_at": event.OccurredAt, "approved_refund_amount": approvedAmount, "remark": "企业微信审批通过", + "failure_reason": "", "failure_message": "", "updated_at": event.OccurredAt, }) if result.Error != nil { @@ -81,23 +138,33 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } + refund.Status = targetStatus } - switch order.PaymentStatus { - case model.PaymentStatusPaid: - changed = true - result := tx.WithContext(ctx).Model(&model.Order{}). - Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid). - Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt}) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败") + if !originalRoute { + switch order.PaymentStatus { + case model.PaymentStatusPaid: + changed = true + result := tx.WithContext(ctx).Model(&model.Order{}). + Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid). + Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "订单退款状态已变化") + } + order.PaymentStatus = model.PaymentStatusRefunded + case model.PaymentStatusRefunded: + default: + return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款") } - if result.RowsAffected != 1 { - return errors.New(errors.CodeConflict, "订单退款状态已变化") + } else { + if order.PaymentStatus != model.PaymentStatusPaid && order.PaymentStatus != model.PaymentStatusRefunded { + return errors.New(errors.CodeInvalidStatus, "订单状态不允许原路退款") + } + if err := s.prepareChannelRefundInTx(ctx, tx, &refund, attempt); err != nil { + return err } - order.PaymentStatus = model.PaymentStatusRefunded - case model.PaymentStatusRefunded: - default: - return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款") } if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil { return err @@ -112,8 +179,10 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T }); err != nil { return err } - if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil { - return err + if !originalRoute { + if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil { + return err + } } if err := commissiondelivery.AppendRefundCommissionDeduct(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil { return err @@ -134,6 +203,17 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T return nil } +// prepareChannelRefundInTx 在企微通过事务内登记原路退款的待执行事实。 +// +// 只写本地事实与可靠事件:请求号在提交时已冻结到审批尝试记录,这里把它落到退款单并转入 +// 原路处理中;真正的渠道调用由可靠事件驱动的事务外消费者执行(ENG-TX-001)。 +func (s *Service) prepareChannelRefundInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error { + if s.channelRefund == nil { + return errors.New(errors.CodeInternalError, "渠道原路退款能力未配置") + } + return s.channelRefund.PrepareInTx(ctx, tx, refund, attempt) +} + func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) reason := map[string]string{ diff --git a/internal/service/refund/attempt_query.go b/internal/service/refund/attempt_query.go new file mode 100644 index 0000000..466124a --- /dev/null +++ b/internal/service/refund/attempt_query.go @@ -0,0 +1,87 @@ +package refund + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "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" +) + +// loadAttemptResponses 批量读取退款申请的审批尝试历史,并补齐每次尝试的审批实例状态。 +// +// 尝试记录按提交次序升序返回,历史材料与审批结果不被覆盖;未接入尝试模式的存量申请返回空切片。 +func (s *Service) loadAttemptResponses(ctx context.Context, refunds []*model.RefundRequest) (map[uint][]dto.RefundAttemptResponse, error) { + result := make(map[uint][]dto.RefundAttemptResponse, len(refunds)) + refundIDs := make([]uint, 0, len(refunds)) + for _, refund := range refunds { + if refund == nil || refund.ID == 0 { + continue + } + refundIDs = append(refundIDs, refund.ID) + result[refund.ID] = []dto.RefundAttemptResponse{} + } + if len(refundIDs) == 0 { + return result, nil + } + + var attempts []model.RefundRequestAttempt + if err := s.db.WithContext(ctx). + Where("refund_id IN ?", refundIDs). + Order("refund_id ASC, attempt_no ASC, id ASC"). + Find(&attempts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败") + } + if len(attempts) == 0 { + return result, nil + } + + instanceIDs := make([]uint, 0, len(attempts)) + for index := range attempts { + if attempts[index].ApprovalInstanceID != nil && *attempts[index].ApprovalInstanceID > 0 { + instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID) + } + } + statuses := map[uint]int{} + if len(instanceIDs) > 0 { + var instances []model.ApprovalInstance + if err := s.db.WithContext(ctx).Select("id", "status").Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批尝试实例状态失败") + } + for _, instance := range instances { + statuses[instance.ID] = instance.Status + } + } + + for index := range attempts { + attempt := &attempts[index] + item := dto.RefundAttemptResponse{ + ID: attempt.ID, + AttemptNo: attempt.AttemptNo, + Method: attempt.Method, + MethodName: constants.RefundMethodName(attempt.Method), + RefundAmount: attempt.RefundAmount, + FrozenActualReceivedAmount: attempt.FrozenActualReceivedAmount, + RefundReason: attempt.RefundReason, + CustomerAccountInfo: attempt.CustomerAccountInfo, + CustomerVoucherKey: []string(attempt.CustomerVoucherKeys), + ChannelRefundRequestNo: attempt.ChannelRefundRequestNo, + SubmittedByAccountID: attempt.SubmittedByAccountID, + CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"), + } + if len(item.CustomerVoucherKey) == 0 { + item.CustomerVoucherKey = []string{} + } + if attempt.ApprovalInstanceID != nil && *attempt.ApprovalInstanceID > 0 { + item.ApprovalInstanceID = *attempt.ApprovalInstanceID + if status, exists := statuses[*attempt.ApprovalInstanceID]; exists { + value := status + item.ApprovalStatus = &value + item.ApprovalStatusName = constants.GetApprovalStatusName(status) + } + } + result[attempt.RefundID] = append(result[attempt.RefundID], item) + } + return result, nil +} diff --git a/internal/service/refund/method.go b/internal/service/refund/method.go new file mode 100644 index 0000000..b9b548f --- /dev/null +++ b/internal/service/refund/method.go @@ -0,0 +1,273 @@ +package refund + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + + refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// refundMethodOption 描述一种可选退款方式及其不可用的原因。 +type refundMethodOption struct { + Method string + Name string + Available bool + // Reason 在方式不可用时说明具体原因,供前端禁用并展示。 + Reason string +} + +// refundMethodDecision 是一次退款方式判定的完整结果。 +type refundMethodDecision struct { + Options []refundMethodOption + // FrozenActualReceivedAmount 是系统派生的权威实收金额(分),提交人不可填写或修改。 + FrozenActualReceivedAmount int64 + // Payment 是派生实收金额时使用的原成功支付记录,线上订单才有值。 + Payment *model.Payment + // ChannelConfig 是原路退款实际使用的冻结商户当前凭证,非线上订单或原路不可用时为 nil。 + ChannelConfig *model.WechatConfig +} + +// decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。 +// +// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单 +// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。 +func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) { + if order == nil || order.ID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效") + } + + decision := &refundMethodDecision{} + switch order.PaymentMethod { + case model.PaymentMethodWechat, model.PaymentMethodAlipay: + payment, err := s.loadPaidPackagePayment(ctx, order.ID) + if err != nil { + return nil, err + } + if payment.Amount <= 0 { + return nil, errors.New(errors.CodeInvalidParam, "原成功支付记录金额非正,无法确定权威实收金额") + } + decision.Payment = payment + decision.FrozenActualReceivedAmount = payment.Amount + originalRoute, reason, config := s.originalRouteAvailability(ctx, order, payment) + if originalRoute { + decision.ChannelConfig = config + } + decision.Options = []refundMethodOption{ + {Method: constants.RefundMethodOriginalRoute, Name: constants.RefundMethodName(constants.RefundMethodOriginalRoute), Available: originalRoute, Reason: reason}, + {Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true}, + } + case model.PaymentMethodWallet: + amount, err := orderActualPaidAmount(order) + if err != nil { + return nil, err + } + decision.FrozenActualReceivedAmount = amount + switch order.BuyerType { + case model.BuyerTypeAgent: + decision.Options = []refundMethodOption{ + {Method: constants.RefundMethodAgentWallet, Name: constants.RefundMethodName(constants.RefundMethodAgentWallet), Available: true}, + } + case model.BuyerTypePersonal: + decision.Options = []refundMethodOption{ + {Method: constants.RefundMethodAssetWallet, Name: constants.RefundMethodName(constants.RefundMethodAssetWallet), Available: true}, + } + default: + return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款") + } + case model.PaymentMethodOffline: + amount, err := orderActualPaidAmount(order) + if err != nil { + return nil, err + } + decision.FrozenActualReceivedAmount = amount + decision.Options = []refundMethodOption{ + {Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true}, + } + default: + return nil, errors.New(errors.CodeInvalidParam, "该订单的支付方式不支持退款") + } + return decision, nil +} + +// orderActualPaidAmount 取订单实际收款或实际扣款金额作为权威实收金额。 +func orderActualPaidAmount(order *model.Order) (int64, error) { + if order.ActualPaidAmount == nil || *order.ActualPaidAmount <= 0 { + return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或非正,无法确定权威实收金额") + } + return *order.ActualPaidAmount, nil +} + +// loadPaidPackagePayment 读取该订单原成功的线上支付记录。 +func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) { + var payment model.Payment + err := s.db.WithContext(ctx). + Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). + Order("id DESC"). + First(&payment).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidParam, "未找到该订单的原成功支付记录,无法确定权威实收金额") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付记录失败") + } + return &payment, nil +} + +// originalRouteAvailability 预检线上订单的原路退款可退性,并返回实际使用的商户凭证。 +// +// 条件全部本地可判定:存在成功支付记录、可定位实际收款商户、该商户具备其服务商类型的 +// 退款能力与必需凭证、且原交易未超出渠道可退时限。实际调用渠道后的结果才是退款资格的 +// 最终判断;预检只用于在申请阶段禁用原路并说明原因。 +func (s *Service) originalRouteAvailability(ctx context.Context, order *model.Order, payment *model.Payment) (bool, string, *model.WechatConfig) { + if payment == nil { + return false, "缺少原成功支付记录", nil + } + if strings.TrimSpace(payment.ThirdPartyTradeNo) == "" { + return false, "原支付记录缺少渠道交易流水号", nil + } + if s.paymentMerchantRuntime == nil { + return false, "商户凭证加载能力未配置", nil + } + + var config *model.WechatConfig + if payment.MerchantID != nil { + merchant, err := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID) + if err != nil { + return false, "实际收款商户不可用", nil + } + // 商户停用不阻断历史单的原路退款,因此只校验凭证完整性,不校验商户状态。 + config, err = merchantRefundConfig(merchant) + if err != nil { + return false, err.Error(), nil + } + } else { + if payment.PaymentConfigID == nil { + return false, "历史支付单缺少支付配置", nil + } + legacy, err := loadLegacyPaymentConfig(ctx, s.db, *payment.PaymentConfigID) + if err != nil { + return false, err.Error(), nil + } + config = legacy + } + + if reason := channelRefundCredentialIssue(config); reason != "" { + return false, reason, nil + } + if reason := channelRefundWindowIssue(config.ProviderType, payment.PaidAt); reason != "" { + return false, reason, nil + } + return true, "", config +} + +// channelRefundWindowIssue 判定原交易是否超出渠道可退时限。 +// 富友按是否回传原交易日期区分:不回传仅支持 30 天内,回传可退 360 天内。 +// 系统始终回传原交易日期,因此按 360 天判定;微信与支付宝不设本地时限。 +func channelRefundWindowIssue(providerType string, paidAt *time.Time) string { + if providerType != model.ProviderTypeFuiou { + return "" + } + if paidAt == nil { + return "富友原交易缺少支付时间,无法回传原交易日期" + } + if time.Since(*paidAt) > 360*24*time.Hour { + return "富友原交易已超出渠道可退时限(360 天)" + } + return "" +} + +// requestChannelRefundNo 按冻结商户生成渠道退款请求号,并在提交时冻结到审批尝试记录。 +// 重提会生成新值;同一尝试的渠道重试复用该值,因此渠道侧以它作为幂等标识。 +func requestChannelRefundNo(config *model.WechatConfig, now time.Time) string { + return refundchannel.BuildChannelRefundRequestNo(merchantChannelPrefix(config), now) +} + +// merchantChannelPrefix 取商户标识中的数字段作为请求号前缀。 +// 富友要求前缀为其机构码;其余渠道只要求请求号在其长度与字符集约束内唯一。 +func merchantChannelPrefix(config *model.WechatConfig) string { + if config == nil { + return "0000" + } + if config.ProviderType == model.ProviderTypeFuiou && strings.TrimSpace(config.FyInsCd) != "" { + return config.FyInsCd + } + return config.WxMchID + config.AliAppID +} + +// validateRefundMethod 校验提交的退款方式属于该订单的可选方式集合。 +func validateRefundMethod(decision *refundMethodDecision, method string) error { + method = strings.TrimSpace(method) + if method == "" { + return errors.New(errors.CodeInvalidParam, "退款方式不能为空") + } + for _, option := range decision.Options { + if option.Method != method { + continue + } + if !option.Available { + return errors.New(errors.CodeInvalidParam, "该订单当前不支持此退款方式:"+option.Reason) + } + return nil + } + return errors.New(errors.CodeInvalidParam, "该订单不支持此退款方式") +} + +// validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。 +// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。 +func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error { + if method != constants.RefundMethodCustomerAccount { + return nil + } + if strings.TrimSpace(customerAccountInfo) == "" { + return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息") + } + hasVoucher := false + for _, key := range voucherKeys { + if strings.TrimSpace(key) != "" { + hasVoucher = true + break + } + } + if !hasVoucher { + return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须至少上传一个客户收款凭证") + } + return nil +} + +// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。 +func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error { + if amount <= 0 { + return errors.New(errors.CodeInvalidParam, "退款金额必须为正分") + } + if frozenActualReceivedAmount <= 0 { + return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请") + } + if amount > frozenActualReceivedAmount { + return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额") + } + return nil +} + +// buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录。 +func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt { + attempt := &model.RefundRequestAttempt{ + RefundID: refund.ID, + Method: refund.Method, + RefundAmount: refund.RequestedRefundAmount, + FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount, + RefundReason: refund.RefundReason, + CustomerAccountInfo: refund.CustomerAccountInfo, + CustomerVoucherKeys: refund.RefundVoucherKey, + SubmittedByAccountID: refund.Creator, + } + if refund.Method == constants.RefundMethodOriginalRoute { + attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now) + } + return attempt +} diff --git a/internal/service/refund/payment_merchant.go b/internal/service/refund/payment_merchant.go index 8d31055..ff2f7ef 100644 --- a/internal/service/refund/payment_merchant.go +++ b/internal/service/refund/payment_merchant.go @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -16,10 +17,10 @@ func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoad s.paymentMerchantRuntime = runtime } -// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载和校验支付凭证;本 Change -// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。 -// merchant_id 为空仅是留存期内的历史线上支付,独立 Change 清理后才可删除按 -// payment_config_id 读取的兼容路径,绝不能按当前商户池推断商户。 +// preparePaymentRefundCredentials 在企微通过事务内装载并校验原路退款所需凭证。 +// +// 钱包支付和线下支付没有收款商户凭证,直接放行。merchant_id 为空仅表示留存期内的历史 +// 线上支付,仍按 payment_config_id 读取兼容配置;绝不能按当前商户池推断历史商户。 func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error { var payment model.Payment err := tx.WithContext(ctx). @@ -38,7 +39,7 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm. } if payment.MerchantID != nil { if s.paymentMerchantRuntime == nil { - return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置") + return errors.New(errors.CodeServiceUnavailable, "商户凭证加载能力未配置") } merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID) if loadErr != nil { @@ -59,36 +60,58 @@ func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm. return nil } -// validateFrozenMerchantRefundCredentials 只判断既有渠道流程所需凭证是否完整。 -// 当前系统没有任何渠道原路退款 Adapter,因此返回前不调用微信、支付宝或富友。 +// validateFrozenMerchantRefundCredentials 判断冻结商户是否具备原路退款能力。 +// +// 能力只由服务商类型与该服务商类型退款所需凭证的完整性决定,不提供人工开关: +// - 微信直连 v3:具备 v3 密钥、证书、私钥与证书序列号时可调用 v3 退款接口; +// - 微信 v2:其退款接口需要 API 客户端证书,而商户凭证键集合不含该证书,故判定不可用; +// - 富友:凭证键集合已含退款与退款查询所需参数,能力可用; +// - 支付宝:具备 AppID、应用私钥与支付宝公钥时可签名退款请求。 func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error { - config, err := merchantpayment.MerchantConfig(merchant, nil) + config, err := merchantRefundConfig(merchant) if err != nil { return err } - switch config.ProviderType { - case model.ProviderTypeWechat: - if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) { - return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整") - } - case model.ProviderTypeWechatV2: - if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) { - return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整") - } - case model.ProviderTypeFuiou: - if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) { - return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整") - } - case "alipay": - if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) { - return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整") - } - default: - return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程") + if reason := channelRefundCredentialIssue(config); reason != "" { + return errors.New(errors.CodeNoPaymentConfig, reason) } return nil } +// merchantRefundConfig 把冻结商户凭证适配为渠道配置,供退款能力判定与渠道调用使用。 +func merchantRefundConfig(merchant *model.PaymentMerchant) (*model.WechatConfig, error) { + if merchant == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在") + } + return merchantpayment.MerchantConfig(merchant, nil) +} + +// loadLegacyPaymentConfig 按历史支付配置标识读取兼容配置。 +func loadLegacyPaymentConfig(ctx context.Context, db *gorm.DB, configID uint) (*model.WechatConfig, error) { + if db == nil || configID == 0 { + return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用") + } + var legacy model.WechatConfig + if err := db.WithContext(ctx).Unscoped().First(&legacy, configID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败") + } + return &legacy, nil +} + +// channelRefundCredentialIssue 返回该商户凭证不满足原路退款要求的原因;凭证完整时返回空串。 +// +// 判定规则只有一处来源:refundchannel.RefundCredentialIssue。申请阶段的方式预检与执行阶段的 +// 凭证校验必须完全一致,否则会出现「申请时可选原路、执行时凭证不完整」的不一致。 +func channelRefundCredentialIssue(config *model.WechatConfig) string { + if config == nil { + return "商户支付凭证不可用" + } + return refundchannelapp.RefundCredentialIssue(config.ProviderType, config) +} + func blank(values ...string) bool { for _, value := range values { if strings.TrimSpace(value) == "" { diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 0ec50eb..3fa524e 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -19,6 +19,7 @@ import ( merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" + refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" @@ -60,6 +61,12 @@ type Service struct { logger *zap.Logger paymentMerchantRuntime *merchantpayment.RuntimeLoader refundOffset *employeecollectionapp.RefundOffsetService + channelRefund *refundchannelapp.Service +} + +// SetChannelRefundService 注入渠道原路退款用例。 +func (s *Service) SetChannelRefundService(service *refundchannelapp.Service) { + s.channelRefund = service } // SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。 @@ -121,7 +128,8 @@ func (s *Service) SetLifecycleAudit(writer *audit.Writer) { } // Create 创建退款申请 -// 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 +// 校验订单存在且已支付、派生并冻结权威实收金额、判定可选退款方式,随后原子创建退款申请、 +// 审批尝试记录与企业微信审批实例。实收金额由系统派生,提交人填写无效。 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { @@ -139,13 +147,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt } return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款") } - if err := validateRequestedRefundAmountByOrder(req.RequestedRefundAmount, order); err != nil { + + decision, err := s.decideRefundMethods(ctx, order) + if err != nil { + return nil, err + } + if err := validateRefundMethod(decision, req.Method); err != nil { + return nil, err + } + if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil { return nil, err } refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey) if err != nil { return nil, err } + if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil { + return nil, err + } // 从订单获取 shop_id:优先使用 SellerShopID,代理商买家使用 BuyerID var shopID *uint @@ -156,20 +175,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt } refund := &model.RefundRequest{ - RefundNo: generateRefundNo(), - OrderID: req.OrderID, - OrderNo: order.OrderNo, - OrderType: order.OrderType, - AssetIdentifier: order.AssetIdentifier, - IotCardID: order.IotCardID, - DeviceID: order.DeviceID, - PackageUsageID: req.PackageUsageID, - ShopID: shopID, - ActualReceivedAmount: req.ActualReceivedAmount, - RequestedRefundAmount: req.RequestedRefundAmount, - RefundVoucherKey: refundVoucherKey, - RefundReason: req.RefundReason, - Status: model.RefundStatusPending, + RefundNo: generateRefundNo(), + OrderID: req.OrderID, + OrderNo: order.OrderNo, + OrderType: order.OrderType, + AssetIdentifier: order.AssetIdentifier, + IotCardID: order.IotCardID, + DeviceID: order.DeviceID, + PackageUsageID: req.PackageUsageID, + ShopID: shopID, + // 实收金额与冻结额一律取系统派生值,忽略提交人传入的金额。 + ActualReceivedAmount: decision.FrozenActualReceivedAmount, + FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount, + RequestedRefundAmount: req.RequestedRefundAmount, + Method: req.Method, + CustomerAccountInfo: req.CustomerAccountInfo, + RefundVoucherKey: refundVoucherKey, + RefundReason: req.RefundReason, + Status: model.RefundStatusPending, } refund.Creator = userID refund.Updater = userID @@ -177,8 +200,9 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt if s.refundApprovalCreation == nil { return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } + attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now()) result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{ - Refund: refund, Order: order, SubmitterAccountID: userID, + Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt, }) if err != nil { failedRefund := *refund @@ -229,12 +253,17 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re if err != nil { return nil, err } + attemptResponses, err := s.loadAttemptResponses(ctx, requests) + if err != nil { + return nil, err + } items := make([]dto.RefundResponse, 0, len(requests)) for _, r := range requests { item := buildRefundResponse(r) item.SubmitterName = submitterNames[r.Creator] - applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID) + applyApprovalSummary(item, approvalSummaries, r) + item.Attempts = attemptResponses[r.ID] items = append(items, *item) } @@ -279,7 +308,12 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er if err != nil { return nil, err } - applyApprovalSummary(resp, approvalSummaries, refund.ApprovalInstanceID) + applyApprovalSummary(resp, approvalSummaries, refund) + attemptResponses, err := s.loadAttemptResponses(ctx, []*model.RefundRequest{refund}) + if err != nil { + return nil, err + } + resp.Attempts = attemptResponses[refund.ID] return resp, nil } @@ -334,8 +368,10 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe beforeRefund := refundAuditState(refund) beforeOrder := map[string]any{"payment_status": order.PaymentStatus} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 条件更新同时约束 approval_instance_id IS NULL:已关联审批实例的申请由企业微信决定, + // 本地人工通过一律拒绝,并与 Reject/Return 的守卫保持一致(ENG-CONC-001)。 result := tx.Model(&model.RefundRequest{}). - Where("id = ? AND status = ?", id, model.RefundStatusPending). + Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusApproved, "processor_id": userID, @@ -349,7 +385,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败") } if result.RowsAffected == 0 { - return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") + return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,或状态已变更,不能人工审批") } orderResult := tx.Model(&model.Order{}). @@ -391,6 +427,12 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return nil } +// AppendCompletedNotification 在同一事务内补写退款完成通知事实。 +// 供渠道原路退款在渠道明确成功时调用:该方式的退款完成时点晚于企微通过。 +func (s *Service) AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error { + return s.appendCompletedNotification(ctx, tx, refund) +} + // appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。 func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error { if refund == nil || refund.ShopID == nil { @@ -717,6 +759,11 @@ func ensureRefundProcessor(ctx context.Context) error { // Resubmit 重新提交退款申请 // 条件更新 WHERE status=4(已退回),修改部分字段后重新进入待审批状态 +// Resubmit 修改并重提未成功退款申请 +// POST /api/admin/refunds/:id/resubmit +// 仅已拒绝、已退回或原路退款失败且不存在审批异常的申请可修改重提;每次重提新增一条不可变 +// 审批尝试记录与一个新的企业微信审批实例,历史材料与审批结果不被覆盖。金额与方式的变更 +// 必须重新审批,不得沿用旧实例。 func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { @@ -725,19 +772,43 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund refund, err := s.refundStore.GetByIDForOperation(ctx, id) if err != nil { - return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") + return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo}) - if refund.Status != model.RefundStatusReturned { - businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") + if err := s.resubmitPrecheck(refund); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err) + return err + } + + order, err := s.orderStore.GetByID(ctx, refund.OrderID) + if err != nil { + businessErr := errors.New(errors.CodeNotFound, "订单不存在") s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr) return businessErr } + decision, err := s.decideRefundMethods(ctx, order) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + return err + } + // 方式缺省沿用原方式,金额缺省沿用原金额。 + method := refund.Method + if req.Method != nil && strings.TrimSpace(*req.Method) != "" { + method = *req.Method + } + if err := validateRefundMethod(decision, method); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + return err + } requestedRefundAmount := refund.RequestedRefundAmount if req.RequestedRefundAmount != nil { requestedRefundAmount = *req.RequestedRefundAmount } + if err := validateFrozenRefundAmount(requestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + return err + } refundVoucherKey := refund.RefundVoucherKey if req.RefundVoucherKey != nil { normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey) @@ -747,54 +818,58 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund } refundVoucherKey = normalized } - - order, err := s.orderStore.GetByID(ctx, refund.OrderID) - if err != nil { - businessErr := errors.New(errors.CodeNotFound, "订单不存在") - s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr) - return businessErr + refundReason := refund.RefundReason + if req.RefundReason != nil { + refundReason = *req.RefundReason } - if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil { - s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + customerAccountInfo := refund.CustomerAccountInfo + if req.CustomerAccountInfo != nil { + customerAccountInfo = *req.CustomerAccountInfo + } + if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err) return err } - now := time.Now() - updates := map[string]any{ - "status": model.RefundStatusPending, - "updater": userID, - "updated_at": now, - } - if req.ActualReceivedAmount != nil { - updates["actual_received_amount"] = *req.ActualReceivedAmount - } - if req.RequestedRefundAmount != nil { - updates["requested_refund_amount"] = *req.RequestedRefundAmount - } - if req.RefundVoucherKey != nil { - updates["refund_voucher_key"] = refundVoucherKey - } - if req.RefundReason != nil { - updates["refund_reason"] = *req.RefundReason - } + // 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。 + updated := *refund + updated.Method = method + updated.RequestedRefundAmount = requestedRefundAmount + updated.FrozenActualReceivedAmount = decision.FrozenActualReceivedAmount + updated.ActualReceivedAmount = decision.FrozenActualReceivedAmount + updated.RefundVoucherKey = refundVoucherKey + updated.RefundReason = refundReason + updated.CustomerAccountInfo = customerAccountInfo + updated.Creator = userID - beforeRefund := refundAuditState(refund) - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.WithContext(ctx).Model(&model.RefundRequest{}). - Where("id = ? AND status = ?", id, model.RefundStatusReturned). - Updates(updates) - if result.Error != nil { - return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败") - } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") - } - return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交") - }) - if err != nil { - s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now()) + if s.refundApprovalCreation == nil { + return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } - return err + if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{ + Refund: &updated, Attempt: attempt, + }); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) + return err + } + return nil +} + +// resubmitPrecheck 校验退款申请是否处于可重提状态且不存在审批异常。 +// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由超级管理员线下处理。 +func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error { + if refund == nil { + return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") + } + if refund.AnomalyFlag != 0 { + return errors.New(errors.CodeInvalidStatus, "该退款申请存在审批异常,需超级管理员线下处理,不支持重提") + } + for _, status := range model.RefundResubmittableStatuses() { + if refund.Status == status { + return nil + } + } + return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请") } // deductAllCommission 幂等回扣该订单所有已入账佣金。 @@ -1340,40 +1415,60 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { } resp := &dto.RefundResponse{ - ID: r.ID, - RefundNo: r.RefundNo, - OrderID: r.OrderID, - OrderNo: r.OrderNo, - AssetIdentifier: r.AssetIdentifier, - AssetType: assetType, - IotCardID: r.IotCardID, - DeviceID: r.DeviceID, - PackageUsageID: r.PackageUsageID, - ShopID: r.ShopID, - ShopName: r.ShopName, - ActualReceivedAmount: r.ActualReceivedAmount, - RequestedRefundAmount: r.RequestedRefundAmount, - ApprovedRefundAmount: r.ApprovedRefundAmount, - RefundVoucherKey: []string(r.RefundVoucherKey), - RefundReason: r.RefundReason, - Status: r.Status, - StatusName: constants.GetRefundStatusName(r.Status), - ProcessorID: r.ProcessorID, - RejectReason: r.RejectReason, - Remark: r.Remark, - CommissionDeducted: r.CommissionDeducted, - AssetReset: r.AssetReset, - SubmitterID: r.Creator, - ApprovalInstanceID: r.ApprovalInstanceID, - Creator: r.Creator, - Updater: r.Updater, - CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"), + ID: r.ID, + RefundNo: r.RefundNo, + OrderID: r.OrderID, + OrderNo: r.OrderNo, + AssetIdentifier: r.AssetIdentifier, + AssetType: assetType, + IotCardID: r.IotCardID, + DeviceID: r.DeviceID, + PackageUsageID: r.PackageUsageID, + ShopID: r.ShopID, + ShopName: r.ShopName, + ActualReceivedAmount: r.ActualReceivedAmount, + RequestedRefundAmount: r.RequestedRefundAmount, + ApprovedRefundAmount: r.ApprovedRefundAmount, + RefundVoucherKey: []string(r.RefundVoucherKey), + RefundReason: r.RefundReason, + Status: r.Status, + StatusName: constants.GetRefundStatusName(r.Status), + Method: r.Method, + MethodName: constants.RefundMethodName(r.Method), + FrozenActualReceivedAmount: r.FrozenActualReceivedAmount, + CustomerAccountInfo: r.CustomerAccountInfo, + ChannelRefundStatus: r.ChannelRefundStatus, + ChannelRefundStatusName: constants.RefundChannelStatusName(r.ChannelRefundStatus), + ChannelRefundNo: r.ChannelRefundNo, + ChannelRefundRequestNo: r.ChannelRefundRequestNo, + ChannelRefundAmount: r.ChannelRefundAmount, + FailureReason: r.FailureReason, + FailureReasonName: constants.RefundFailureReasonName(r.FailureReason), + FailureMessage: r.FailureMessage, + AnomalyFlag: r.AnomalyFlag, + AnomalyReason: r.AnomalyReason, + LatestAttemptID: r.LatestAttemptID, + LatestApprovalInstanceID: r.LatestApprovalInstanceID, + Attempts: []dto.RefundAttemptResponse{}, + ProcessorID: r.ProcessorID, + RejectReason: r.RejectReason, + Remark: r.Remark, + CommissionDeducted: r.CommissionDeducted, + AssetReset: r.AssetReset, + SubmitterID: r.Creator, + ApprovalInstanceID: r.ApprovalInstanceID, + Creator: r.Creator, + Updater: r.Updater, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"), } if r.ProcessedAt != nil { resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05") } + if r.ChannelRefundedAt != nil { + resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05") + } return resp } @@ -1411,11 +1506,16 @@ func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.Re return summaries, nil } -func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) { - if response == nil || instanceID == nil { +// applyApprovalSummary 用审批实例摘要回填响应的审批状态。 +// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。 +func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) { + if response == nil || refund == nil { return } - summary, exists := summaries[*instanceID] + summary, exists := summaries[refund.LatestApprovalInstanceID] + if !exists && refund.ApprovalInstanceID != nil { + summary, exists = summaries[*refund.ApprovalInstanceID] + } if !exists { return } diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go index f6c3413..4905319 100644 --- a/internal/service/wechat_config/service.go +++ b/internal/service/wechat_config/service.go @@ -87,31 +87,33 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest } config := &model.WechatConfig{ - Name: req.Name, - Description: desc, - ProviderType: req.ProviderType, - IsActive: false, - OaAppID: req.OaAppID, - OaAppSecret: req.OaAppSecret, - OaToken: req.OaToken, - OaAesKey: req.OaAesKey, - OaOAuthRedirectURL: req.OaOAuthRedirectURL, - MiniappAppID: req.MiniappAppID, - MiniappAppSecret: req.MiniappAppSecret, - WxMchID: req.WxMchID, - WxAPIV3Key: req.WxAPIV3Key, - WxAPIV2Key: req.WxAPIV2Key, - WxCertContent: req.WxCertContent, - WxKeyContent: req.WxKeyContent, - WxSerialNo: req.WxSerialNo, - WxNotifyURL: req.WxNotifyURL, - FyInsCd: req.FyInsCd, - FyMchntCd: req.FyMchntCd, - FyTermID: req.FyTermID, - FyPrivateKey: req.FyPrivateKey, - FyPublicKey: req.FyPublicKey, - FyAPIURL: req.FyAPIURL, - FyNotifyURL: req.FyNotifyURL, + Name: req.Name, + Description: desc, + ProviderType: req.ProviderType, + IsActive: false, + OaAppID: req.OaAppID, + OaAppSecret: req.OaAppSecret, + OaToken: req.OaToken, + OaAesKey: req.OaAesKey, + OaOAuthRedirectURL: req.OaOAuthRedirectURL, + MiniappAppID: req.MiniappAppID, + MiniappAppSecret: req.MiniappAppSecret, + WxMchID: req.WxMchID, + WxAPIV3Key: req.WxAPIV3Key, + WxAPIV2Key: req.WxAPIV2Key, + WxCertContent: req.WxCertContent, + WxKeyContent: req.WxKeyContent, + WxSerialNo: req.WxSerialNo, + WxNotifyURL: req.WxNotifyURL, + WxClientCertContent: req.WxClientCertContent, + WxClientKeyContent: req.WxClientKeyContent, + FyInsCd: req.FyInsCd, + FyMchntCd: req.FyMchntCd, + FyTermID: req.FyTermID, + FyPrivateKey: req.FyPrivateKey, + FyPublicKey: req.FyPublicKey, + FyAPIURL: req.FyAPIURL, + FyNotifyURL: req.FyNotifyURL, AliAppID: req.AliAppID, AliPrivateKey: req.AliPrivateKey, @@ -233,6 +235,8 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf s.mergeSensitiveField(&config.WxKeyContent, req.WxKeyContent) s.mergeStringField(&config.WxSerialNo, req.WxSerialNo) s.mergeStringField(&config.WxNotifyURL, req.WxNotifyURL) + s.mergeSensitiveField(&config.WxClientCertContent, req.WxClientCertContent) + s.mergeSensitiveField(&config.WxClientKeyContent, req.WxClientKeyContent) // 富友支付 s.mergeStringField(&config.FyInsCd, req.FyInsCd) diff --git a/migrations/000218_add_refund_attempt_and_channel_refund.down.sql b/migrations/000218_add_refund_attempt_and_channel_refund.down.sql new file mode 100644 index 0000000..cd0ae5e --- /dev/null +++ b/migrations/000218_add_refund_attempt_and_channel_refund.down.sql @@ -0,0 +1,63 @@ +-- 回滚退款审批尝试记录与渠道原路退款事实。 +-- 存在审批尝试记录或渠道退款事实时禁止破坏性回滚: +-- 尝试记录承载每次提交的不可变材料与冻结金额,渠道退款流水是资金事实,均无法由回滚重建。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_refund_request_attempt) THEN + RAISE EXCEPTION '存在退款审批尝试记录,禁止回滚退款审批尝试模式'; + END IF; + + IF EXISTS ( + SELECT 1 FROM tb_refund_request + WHERE channel_refund_status <> 0 OR channel_refund_no <> '' OR channel_refund_request_no <> '' + ) THEN + RAISE EXCEPTION '存在渠道退款事实,禁止回滚退款渠道退款字段'; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_refund_request_channel_recovery; +DROP INDEX IF EXISTS uk_refund_request_active_order; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_status; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_status + CHECK (status IN (1, 2, 3, 4)); + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_amounts; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_anomaly; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_failure_reason; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_channel_refund_status; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_method; + +ALTER TABLE tb_refund_request + DROP COLUMN IF EXISTS anomaly_reason, + DROP COLUMN IF EXISTS anomaly_flag, + DROP COLUMN IF EXISTS failure_message, + DROP COLUMN IF EXISTS failure_reason, + DROP COLUMN IF EXISTS channel_refunded_at, + DROP COLUMN IF EXISTS channel_submitted_at, + DROP COLUMN IF EXISTS channel_refund_amount, + DROP COLUMN IF EXISTS channel_refund_request_no, + DROP COLUMN IF EXISTS channel_refund_no, + DROP COLUMN IF EXISTS channel_refund_status, + DROP COLUMN IF EXISTS customer_account_info, + DROP COLUMN IF EXISTS frozen_actual_received_amount, + DROP COLUMN IF EXISTS method, + DROP COLUMN IF EXISTS latest_approval_instance_id, + DROP COLUMN IF EXISTS latest_attempt_id; + +COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回'; + +DROP TABLE IF EXISTS tb_refund_request_attempt; diff --git a/migrations/000218_add_refund_attempt_and_channel_refund.up.sql b/migrations/000218_add_refund_attempt_and_channel_refund.up.sql new file mode 100644 index 0000000..f7cbe2c --- /dev/null +++ b/migrations/000218_add_refund_attempt_and_channel_refund.up.sql @@ -0,0 +1,173 @@ +-- 退款审批尝试记录与渠道原路退款事实。 +-- 每次提交或重提新增一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、 +-- 客户收款信息、凭证与套餐使用快照;尝试记录主键同时作为通用审批业务标识, +-- 使同一退款单的每次提交各自持有独立审批实例,历史材料不被覆盖。 +-- 退款单只保存最新尝试与最新实例引用用于展示,其既有 approval_instance_id 语义与唯一约束保持不变。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_refund_request_attempt ( + id BIGSERIAL PRIMARY KEY, + refund_id BIGINT NOT NULL, + attempt_no INTEGER NOT NULL, + method VARCHAR(20) NOT NULL, + refund_amount BIGINT NOT NULL, + frozen_actual_received_amount BIGINT NOT NULL, + refund_reason TEXT NOT NULL DEFAULT '', + customer_account_info TEXT NOT NULL DEFAULT '', + customer_voucher_keys JSONB NOT NULL DEFAULT '[]'::jsonb, + package_usage_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + channel_refund_request_no VARCHAR(64) NOT NULL DEFAULT '', + submitted_by_account_id BIGINT NOT NULL, + approval_instance_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_refund_request_attempt_no UNIQUE (refund_id, attempt_no), + CONSTRAINT chk_refund_request_attempt_ids CHECK (refund_id > 0 AND submitted_by_account_id > 0), + CONSTRAINT chk_refund_request_attempt_no_positive CHECK (attempt_no >= 1), + CONSTRAINT chk_refund_request_attempt_method CHECK (method <> ''), + CONSTRAINT chk_refund_request_attempt_amount CHECK (refund_amount > 0 AND frozen_actual_received_amount >= 0), + CONSTRAINT chk_refund_request_attempt_vouchers CHECK (jsonb_typeof(customer_voucher_keys) = 'array'), + CONSTRAINT chk_refund_request_attempt_usage CHECK (jsonb_typeof(package_usage_snapshot) = 'object'), + CONSTRAINT chk_refund_request_attempt_instance CHECK (approval_instance_id IS NULL OR approval_instance_id > 0) +); + +-- 尝试记录是通用审批业务标识,其审批实例引用必须唯一。 +CREATE UNIQUE INDEX uk_refund_request_attempt_instance + ON tb_refund_request_attempt (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; +CREATE INDEX idx_refund_request_attempt_refund + ON tb_refund_request_attempt (refund_id, attempt_no DESC); + +COMMENT ON TABLE tb_refund_request_attempt IS '退款审批尝试记录,每次提交或重提一条不可变方式、金额与材料快照'; +COMMENT ON COLUMN tb_refund_request_attempt.id IS '主键,审批尝试记录ID,同时作为通用审批业务ID'; +COMMENT ON COLUMN tb_refund_request_attempt.refund_id IS '所属退款申请ID'; +COMMENT ON COLUMN tb_refund_request_attempt.attempt_no IS '第几次提交,从 1 递增,申请内唯一'; +COMMENT ON COLUMN tb_refund_request_attempt.method IS '本次退款方式:original_route 原路退款、customer_account 客户收款信息、asset_wallet 退回原资产钱包、agent_wallet 退回原代理钱包'; +COMMENT ON COLUMN tb_refund_request_attempt.refund_amount IS '本次申请退款金额(分),即企业微信授权金额与最终允许退款金额'; +COMMENT ON COLUMN tb_refund_request_attempt.frozen_actual_received_amount IS '本次冻结的权威实收金额(分),退款金额上限与后续回溯比例分母'; +COMMENT ON COLUMN tb_refund_request_attempt.refund_reason IS '本次退款原因快照'; +COMMENT ON COLUMN tb_refund_request_attempt.customer_account_info IS '本次客户收款信息自由文本快照,仅客户收款信息方式有值'; +COMMENT ON COLUMN tb_refund_request_attempt.customer_voucher_keys IS '本次客户收款凭证对象存储 Key 列表(jsonb 数组)'; +COMMENT ON COLUMN tb_refund_request_attempt.package_usage_snapshot IS '本次冻结的关联套餐使用情况快照(jsonb 对象)'; +COMMENT ON COLUMN tb_refund_request_attempt.channel_refund_request_no IS '本次尝试的渠道退款请求号,同一次尝试重试复用,重提更换'; +COMMENT ON COLUMN tb_refund_request_attempt.submitted_by_account_id IS '本次实际提交账号ID'; +COMMENT ON COLUMN tb_refund_request_attempt.approval_instance_id IS '本次尝试关联的通用审批实例ID,创建后不可修改'; +COMMENT ON COLUMN tb_refund_request_attempt.created_at IS '创建时间'; + +-- 退款单追加展示引用、冻结事实、渠道退款结果与异常标记。 +-- 既有 approval_instance_id 语义(首次接入企业微信审批的实例)保持不变。 +ALTER TABLE tb_refund_request + ADD COLUMN IF NOT EXISTS latest_attempt_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS latest_approval_instance_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS method VARCHAR(20) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS frozen_actual_received_amount BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS customer_account_info TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS channel_refund_status SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS channel_refund_no VARCHAR(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS channel_refund_request_no VARCHAR(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS channel_refund_amount BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS channel_refunded_at TIMESTAMPTZ, + -- 提交认领时点:渠道退款请求「至多提交一次」的持久化依据。 + -- 提交前先条件认领本列,认领失败表示本次尝试已提交过,只允许查询、不得再次提交。 + ADD COLUMN IF NOT EXISTS channel_submitted_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(32) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS failure_message VARCHAR(500) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS anomaly_flag SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS anomaly_reason VARCHAR(500) NOT NULL DEFAULT ''; + +-- 存量退款单没有渠道退款事实:冻结实收沿用既有实收字段,方法留空表示未接入方式的存量申请。 +UPDATE tb_refund_request +SET frozen_actual_received_amount = actual_received_amount +WHERE frozen_actual_received_amount = 0 AND actual_received_amount > 0; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_method; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_method + CHECK (method IN ('', 'original_route', 'customer_account', 'asset_wallet', 'agent_wallet')); + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_channel_refund_status; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_channel_refund_status + CHECK (channel_refund_status IN (0, 1, 2, 3)); + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_failure_reason; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_failure_reason + CHECK (failure_reason IN ('', 'channel_rejected', 'credential_invalid', 'insufficient_balance', + 'timeout_unknown', 'approval_rejected', 'revoked_after_approved', + 'payment_fact_invalid')); + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_anomaly; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_anomaly + CHECK (anomaly_flag IN (0, 1)); + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_amounts; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_amounts + CHECK (frozen_actual_received_amount >= 0 AND channel_refund_amount >= 0); + +-- 退款状态新增原路退款处理中(5)与原路退款失败(6);既有 1-4 语义不变。 +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_status; + +ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_status + CHECK (status IN (1, 2, 3, 4, 5, 6)); + +COMMENT ON COLUMN tb_refund_request.latest_attempt_id IS '最新审批尝试记录ID,仅用于列表投影与历史事实定位'; +COMMENT ON COLUMN tb_refund_request.latest_approval_instance_id IS '最新通用审批实例ID,仅用于列表投影'; +COMMENT ON COLUMN tb_refund_request.method IS '退款方式:空表示未接入方式的存量申请,取值同审批尝试记录'; +COMMENT ON COLUMN tb_refund_request.frozen_actual_received_amount IS '冻结的权威实收金额(分),由系统从原成功支付记录或订单实际收款派生'; +COMMENT ON COLUMN tb_refund_request.customer_account_info IS '当前客户收款信息自由文本快照,仅客户收款信息方式有值'; +COMMENT ON COLUMN tb_refund_request.channel_refund_status IS '渠道退款状态:0 未发起或不适用、1 处理中、2 明确成功、3 明确失败'; +COMMENT ON COLUMN tb_refund_request.channel_refund_no IS '渠道退款流水号,仅渠道明确成功时写入'; +COMMENT ON COLUMN tb_refund_request.channel_refund_request_no IS '本次渠道退款请求号快照,重试复用、重提更换'; +COMMENT ON COLUMN tb_refund_request.channel_refund_amount IS '渠道退款金额快照(分)'; +COMMENT ON COLUMN tb_refund_request.channel_refunded_at IS '渠道明确退款成功时间'; +COMMENT ON COLUMN tb_refund_request.channel_submitted_at IS '渠道退款请求提交认领时间,非空表示该尝试已提交过渠道退款请求,重投只允许查询'; +COMMENT ON COLUMN tb_refund_request.failure_reason IS '结构化失败分类稳定编码,空表示无失败'; +COMMENT ON COLUMN tb_refund_request.failure_message IS '失败安全摘要,不记录凭证内容或凭证原文'; +COMMENT ON COLUMN tb_refund_request.anomaly_flag IS '正交异常标记:0 无异常、1 企业微信通过后撤销或渠道结果永久未知,转人工处理'; +COMMENT ON COLUMN tb_refund_request.anomaly_reason IS '异常原因,供退款详情展示与人工处理'; +COMMENT ON COLUMN tb_refund_request.status IS '状态 1-待审批 2-已通过 3-已拒绝 4-已退回 5-原路退款处理中 6-原路退款失败'; + +-- 活动退款集合与 Go 常量对应: +-- 1 = RefundStatusPending(待审批) +-- 5 = RefundStatusChannelProcessing(原路退款处理中) +-- 6 = RefundStatusChannelFailed(原路退款失败) +-- 活动集合与常量漂移会导致并发重复申请,修改常量时必须同步修改本索引谓词。 +-- 创建索引前先探测活动集合内的重复订单;存在重复时明确失败并中止整次迁移,不自动改写历史数据。 +DO $$ +DECLARE + duplicated_order BIGINT; +BEGIN + SELECT order_id INTO duplicated_order + FROM tb_refund_request + WHERE deleted_at IS NULL AND status IN (1, 5, 6) + GROUP BY order_id + HAVING COUNT(*) > 1 + LIMIT 1; + + IF duplicated_order IS NOT NULL THEN + RAISE EXCEPTION '订单 % 存在多张活动退款申请,禁止创建活动退款唯一索引', duplicated_order; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_refund_request_active_order + ON tb_refund_request (order_id) + WHERE deleted_at IS NULL AND status IN (1, 5, 6); + +-- 渠道退款执行与恢复按处理中状态扫描;该索引只覆盖需要恢复的少量记录。 +CREATE INDEX IF NOT EXISTS idx_refund_request_channel_recovery + ON tb_refund_request (id) + WHERE deleted_at IS NULL AND status = 5 AND channel_refund_status = 1; diff --git a/migrations/000219_add_wechat_v2_client_cert_credentials.down.sql b/migrations/000219_add_wechat_v2_client_cert_credentials.down.sql new file mode 100644 index 0000000..86e2c36 --- /dev/null +++ b/migrations/000219_add_wechat_v2_client_cert_credentials.down.sql @@ -0,0 +1,6 @@ +-- 回滚微信支付 v2 退款所需的 API 客户端证书凭证列。 +-- 证书内容可由管理员重新上传,属可重建配置,因此本回滚不做存在性阻断。 + +ALTER TABLE tb_wechat_config + DROP COLUMN IF EXISTS wx_client_key_content, + DROP COLUMN IF EXISTS wx_client_cert_content; diff --git a/migrations/000219_add_wechat_v2_client_cert_credentials.up.sql b/migrations/000219_add_wechat_v2_client_cert_credentials.up.sql new file mode 100644 index 0000000..40cd0b5 --- /dev/null +++ b/migrations/000219_add_wechat_v2_client_cert_credentials.up.sql @@ -0,0 +1,13 @@ +-- 为微信支付 v2 商户补充退款接口所需的 API 客户端证书凭证。 +-- 微信支付 v2 退款接口(/secapi/pay/refund)请求需要双向证书,因此仅有 wx_api_v2_key 不足以退款; +-- 新增两列分别保存商户 API 证书(apiclient_cert.pem)与证书私钥(apiclient_key.pem)内容。 +-- 商户池新单的凭证保存在 tb_payment_merchant.credentials(JSONB),无需改表; +-- 本迁移只补齐历史综合支付配置表,使按 payment_config_id 读取的历史 v2 支付同样可判定退款能力。 +-- 存量行两列为空,其退款能力按凭证不完整判定为不可用,需由管理员补录后生效;不回填、不改写既有凭证。 + +ALTER TABLE tb_wechat_config + ADD COLUMN IF NOT EXISTS wx_client_cert_content TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS wx_client_key_content TEXT NOT NULL DEFAULT ''; + +COMMENT ON COLUMN tb_wechat_config.wx_client_cert_content IS '微信支付 API 客户端证书内容(apiclient_cert.pem),v2 退款双向证书所需'; +COMMENT ON COLUMN tb_wechat_config.wx_client_key_content IS '微信支付 API 客户端证书私钥内容(apiclient_key.pem),v2 退款双向证书所需'; diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/design.md b/openspec/changes/add-refund-methods-and-original-route-refunds/design.md index 37e174f..8840d59 100644 --- a/openspec/changes/add-refund-methods-and-original-route-refunds/design.md +++ b/openspec/changes/add-refund-methods-and-original-route-refunds/design.md @@ -1,38 +1,241 @@ ## Context -退款服务已有申请、企业微信审批和钱包审计路径,但现有状态/人工入口不足以表达渠道退款未知结果。支付商户池 Change 完成后,新支付单可读取冻结实际商户;历史单继续走旧支付配置。 +现状(开工核对确认,均为可达代码事实): + +- 退款创建 100% 经 `internal/application/refundapproval/creation.go` 在同一事务创建退款单与审批实例,`business_id` 取退款单 ID;`tb_approval_instance` 的 `uq_approval_instance_business UNIQUE (business_type, business_id)`(`migrations/000170_create_approval_core.up.sql:12`)因此结构性地禁止同一退款单拥有第二个审批实例。 +- 重提入口 `POST /api/admin/refunds/:id/resubmit` 只接受已退回状态,直接改金额回待审批且不新建实例(`internal/service/refund/service.go:720`)。 +- 本地人工终审默认开放(`legacy_refund_manual_enabled: true`);`Approve` 的条件更新缺少 `approval_instance_id IS NULL`,`Reject`/`Return` 已具备(`service.go:315`、`:641`、`:685`)。 +- 实收金额由请求体自填且不校验(`internal/model/dto/refund_dto.go:6`),退款金额上限只比 `order.actual_paid_amount`。 +- 活动退款互斥只有 `pg_advisory_xact_lock(order_id)` 加计数(`creation.go:181-188`),无数据库约束。 +- 企微终态唯一入口 `internal/service/refund/approval_decision.go`:事务内写退款单、订单、钱包回款、员工账单冲销、通知/佣金/资产 Outbox 与审计;套餐失效、接续与停机在事务外由 Outbox→Worker 执行(`service.go:1125`);`revoked_after_approved` 当前直接 `return nil`。 +- 渠道侧完全没有退款代码:微信 v3 仅下单/查单/关单/回调,自研微信 v2 仅 `unifiedorder`/`orderquery`,支付宝仅 `TradeWapPay`,富友仅 `preCreate`/`wxPreCreate`/`commonQuery`。退款能力判定已存在且只依据服务商类型与凭证完整性(`internal/service/refund/payment_merchant.go:64`,无人工开关)。 +- 商户加载不过滤状态,停用商户仍可加载(`internal/application/merchantpayment/routing.go:81`)。 + +关键数据边界(测试库 `junhong_cmp_test` 只读核对):43 条退款单中 12 条无审批实例、其中 4 条待审批;2 条申请金额为 0;无订单存在两张活动退款单。 + +## Goals / Non-Goals + +**Goals:** + +- 让原路退款在企微通过后由原实际收款商户的当前凭证可靠执行,结果未知时可恢复且绝不重复扣款。 +- 让每次提交持有独立的、不可变的审批材料与审批实例,且不破坏存量已关联实例的审批消费。 +- 让退款状态机、活动申请互斥与冻结实收金额在数据库层和条件更新层同时闭合。 + +**Non-Goals:** + +- AUG26-012 佣金回溯明细(本 Change 只提供事实契约)。 +- 代理充值预存款业务单的退款。 +- 真实支付渠道实测(见「不做外部渠道实测」)。 +- 对既有状态显示名、既有 `approval_instance_id` 语义或其唯一约束做破坏性改名或重构。 +- 新增任何退款相关的运行时开关(含渠道能力开关、退款方式开关、恢复开关)。 ## Decisions -- 退款单新增方式、冻结实收金额、套餐使用/材料快照、渠道退款状态/流水/安全失败原因和审批实例历史;金额均为分。 -- 创建、审批消费和渠道执行使用同一退款单锁及条件状态更新。一笔订单的活动退款唯一约束防止并发重复;渠道请求使用稳定请求标识,结果未知进入恢复任务而非重发。 -- 企业微信通过事务内完成套餐失效与钱包退款准备;原路渠道调用使用可靠事件,成功回写退款终态。客户收款信息退款以企微通过终态完成。 -- 移除/拒绝本地人工终审语义,保留历史兼容读取;未成功重提始终冻结新实例快照。 +### 1. 渠道退款能力判定只由服务商类型与退款必需凭证完整性决定 -## 行为契约 +在既有 `validateFrozenMerchantRefundCredentials` 的判定上把全部三类服务商都视为具备原路退款能力,并按下列口径修正: -### 退款申请与重提 +| 服务商类型 | 原路退款能力 | 依据 | +| --- | --- | --- | +| `wechat`(微信直连 v3) | 支持 | 凭证含 `wx_mch_id`/`wx_api_v3_key`/`wx_cert_content`/`wx_key_content`/`wx_serial_no`/`wx_notify_url` 时可用 v3 退款接口 | +| `wechat_v2` | 支持(需 APIv2 Key + API 客户端证书) | v2 退款走双向证书接口(`/secapi/pay/refund`,渠道文档明确「请求需要双向证书」),故必需凭证含 `wx_client_cert_content` 与 `wx_client_key_content` | +| `fuiou` | 支持 | 既有凭证键集合已含 `/commonRefund` 与 `/refundQuery` 所需全部参数 | +| `alipay` | 支持 | 凭证含 `ali_app_id`/`ali_private_key`/`ali_public_key` 时可签名退款请求 | -- `POST /refunds`:调用者必须在订单数据范围内。服务锁定订单和既有活动退款,读取订单或原成功支付记录的权威实收金额;缺失、非正或已存在审批中/原路处理中/原路失败申请时拒绝。请求包含退款原因、退款方式、退款金额、客户收款信息及附件(仅客户收款信息方式);金额为正分且不超过冻结实收金额。 -- 服务按实际支付方式生成可选方式:微信/支付宝线上支付为原路或客户收款信息,资产钱包/代理主钱包仅原钱包,后台线下/员工代收仅客户收款信息;不匹配的方式返回“该订单不支持此退款方式”。客户收款信息与至少一个凭证附件必须同时存在,且不读取员工收款方式字典。 -- `PUT /refunds/:id` 或既有重提入口只允许驳回、关闭或渠道明确失败的未成功申请;重新锁定订单和申请,保存新的原因、方式、金额、收款信息、附件和套餐使用快照,创建新的企业微信审批实例。提交失败或审批未知不是可重提状态;已成功、审批中、原路处理中返回状态冲突。 +**结论(已裁决)**:微信支付 v2 **本身支持退款**,其退款接口 `/secapi/pay/refund` 按渠道文档「请求需要双向证书」。因此本 Change 为 `wechat_v2` 扩展两个凭证键并实现该接口: -### 企业微信终审与权益处理 +- 新增凭证键 `wx_client_cert_content`(`apiclient_cert.pem`)与 `wx_client_key_content`(`apiclient_key.pem`),二者对支付/查单**非必填**,只用于退款能力判定与退款调用;不破坏既有 v2 商户的支付与回调。 +- 判定规则:v2 商户凭证含 APIv2 Key **且**含上述两项证书内容时原路退款可用;缺证书时按凭证不完整判定为不可用,仅客户收款信息退款,并返回说明「缺少 API 客户端证书」。 +- 迁移 `000219` 在历史 `tb_wechat_config` 上补齐同名列(存量行为空,补齐后生效);商户池路径的商户凭证存于 `tb_payment_merchant.credentials`(JSONB),无需改表,由 `merchantpayment.MerchantConfig` 自动映射。 +- 证书装载失败(内容非法/不匹配)不阻断 v2 支付与查单:实例不装载证书,退款能力随之判定为不可用。 +- 不提供人工开关;能力仍只由服务商类型与凭证完整性决定。 -- 回调和既有审批恢复任务以审批实例 ID 进入同一幂等用例;移除新业务的本地人工通过、拒绝和退回终审路径,历史接口仅保留兼容读取或明确拒绝。 -- 首次最终通过时锁定退款和订单,校验批准金额不超过冻结实收金额;在事务中标记审批通过、使关联套餐失效、接续下一套餐、评估停机并建立退款执行事实。重复/乱序回调不得再次失效套餐或启动第二次渠道退款。 -- 客户收款信息方式在企业微信通过时标记退款成功;原钱包方式沿用原钱包退款事务;原路方式只写待执行可靠事件,不能在审批事务中假定渠道已成功。 +**渠道结果语义**:渠道文档规定 v2 退款申请接口「返回仅代表业务的受理情况,具体退款是否成功需要通过退款查询接口获取结果」。因此 v2 受理成功映射为**结果未知**(保持原路处理中),由恢复任务查询收敛;`refund_status=SUCCESS` 才算明确成功,`REFUNDCLOSE`/`CHANGE` 为明确失败,`PROCESSING` 保持未知。这一点与微信 v3 不同(v3 申请接口直接返回 `status`)。 -### 原路执行与恢复 +**幂等复用**:v2 的 `out_refund_no` 同样取持久化的渠道退款请求号,重试复用、重提更换,与微信 v3、富友、支付宝一致。 -- 原路执行消费者在调用前锁定退款,验证原支付单、实际收款商户、渠道流水、可退金额和当前商户退款凭证。新支付按冻结 `merchant_id` 加载商户当前凭证,历史支付按 `payment_config_id`;商户停用不阻断历史校验。 -- 以退款 ID/稳定渠道请求号至多提交一次可确认请求。渠道明确成功时保存渠道退款流水并转退款成功;超时、未知、凭证失效、余额不足、拒绝均写安全原因并保持处理中或失败恢复状态,不得标记成功或盲目再次调用。 -- 原路失败后若改为客户收款信息退款,必须修改申请材料并走新企业微信审批;审批通过后撤销只记录审批异常,不恢复套餐权益、不取消已提交渠道退款且不自动重提。 +### 2.1 本地查询窗口:富友 72 小时、微信 v2 7 天 -### 读取与审计 +`channel_submitted_at` 之外的第二个时间边界是本地查询窗口: -- 退款列表、详情和导出返回冻结实收金额、方式、申请/渠道状态、失败安全摘要、审批实例和渠道流水,并按既有订单数据范围过滤。审计记录申请、重提、审批终态、权益处理、渠道调用和恢复,但不得记录凭证内容、完整收款文本或商户密钥。 +| 服务商 | 本地窗口 | 性质 | +| --- | --- | --- | +| 富友 | 72 小时 | **渠道硬约束**:其退款查询接口只支持 3 日内的退款交易,超期后无法再查询 | +| 微信 v2 | 7 天(168 小时) | **本地阈值**:渠道侧无查询时限,此处按本地放弃阈值避免一笔未知结果被无限轮询 | +| 微信 v3 / 支付宝 | 无 | 持续查询直到渠道给出终态 | + +**超期后果(终止本次渠道执行并转人工)**:退款申请转 `status=6`(原路退款失败)、渠道退款状态转 `3`(已失败)、`failure_reason=timeout_unknown`、`anomaly_flag=1` 并写一次异常审计;此后退出轮询,不再查询渠道。 + +三点必须保持的设计约束: + +1. **「未确认」的性质由 `failure_reason` 承载,不由 `status` 承载**。`RefundFailureReasonIsDefinitive(timeout_unknown)` 为 false,因此该退款不会被后续佣金回溯判为「明确失败可回溯」;`status=6` 只表达该尝试的渠道路径已终止。 +2. **不放行自动重提**(保留 `anomaly_flag=1`):本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次提交资金动作,构成重复退款风险。由人工先向渠道核对再决定处置。 +3. **账号级重复保护仍然生效**:`status=6` 属于活动集合 `{1,5,6}`,因此同一订单仍被 `uk_refund_request_active_order` 阻断新建申请。 + +窗口起算点为「进入原路退款处理中」的时点。两条保证该点不会被隐式重置:结果未知的回写与窗口超期终止均使用 `UpdateColumns`,不隐式推进 `updated_at`。 + +### 2.2 资金动作至多提交一次:提交认领 + +`refund.channel.refund.requested` 由 Outbox 至少一次投递(重投、人工重放都可能),因此 `Execute` 必须区分「首次提交」与「重复投递」: + +- 提交前先以 `UPDATE ... SET channel_submitted_at = now() WHERE id = ? AND status = 原路处理中 AND channel_submitted_at IS NULL` 认领提交权; +- **认领成功**才调用渠道退款接口;**认领失败**表示该尝试已提交过,本次只调用退款查询并回填,绝不再次提交资金动作; +- 认领与结果回写同以 `status = 原路处理中` 为谓词,因此并发执行也至多有一次认领成功。 + +这样「至多提交一次可确认的请求」不依赖渠道自身的幂等去重,而是本地持久化事实保证;渠道请求号只是第二道防线。 + +### 2. 富友退款按官方契约实现 + +- 申请退款:`POST /commonRefund`。必填 `version`、`ins_cd`、`mchnt_cd`、`term_id`、`mchnt_order_no`、`random_str`、`sign`、`order_type`、`refund_order_no`、`total_amt`、`refund_amt`;选填 `operator_id`、`reserved_fy_term_id`、`reserved_origi_dt`、`reserved_addn_inf`、`reserved_refund_desc`。响应 `result_code = 000000` 时取 `refund_id`、`transaction_id`、`reserved_refund_amt`、`reserved_fy_settle_dt`。 +- 退款查询:`POST /refundQuery`,入参 `refund_order_no`;`trans_stat` 取值 `SUCCESS` 或 `PAYERROR`;仅支持 3 日内查询。 +- 全局约束:`mchnt_order_no` 与 `refund_order_no` 全局永久唯一;流水号格式为「机构码(4) + 日期(yyyyMMdd) + 随机(8-18 位字母数字)」;重复提交直接拒绝;支持全额退款与多次部分退款。 +- 原交易日期:不传 `reserved_origi_dt` 仅支持 30 天内原交易,传了可退 360 天内原交易。因此原路执行 MUST 回传原支付成功时间作为原交易日期,以覆盖 30–360 天区间;超出 360 天的原交易在创建/提交预检即禁用原路。 +- 回传的原交易标识:`mchnt_order_no` 取原支付单的商户订单号(现实现为 `tb_payment.payment_no`,`internal/infrastructure/payment/fuiou_scan.go:60`),`order_type` 取原交易使用的值(现实现唯一可达值为 `WECHAT` 主扫,`pkg/fuiou/scan.go:20`)。本 Change 按现值回传,并把「未来引入其他富友 `order_type` 时必须冻结原交易 `order_type`」记为已知约束,不回填历史。 + +### 3. 渠道请求号:一次尝试一个稳定值,按富友规则生成 + +每次审批尝试在提交时生成并持久化 `channel_refund_request_no`,同一尝试的重试复用该值,重提生成新值。生成规则统一采用富友规则(机构码 4 位 + 上海时区 `yyyyMMdd` + 8–18 位字母数字,总长 ≤ 30):该值同时满足微信 v3 与支付宝的字母数字与长度约束,也满足微信 v2 的 32 位上限,因此三类渠道共用一个生成器,无需按渠道分支。 + +生成时点选在提交而非执行,是因为提交已必须加载冻结实际商户做能力判定,`fy_ins_cd` 在该时点可得;若在执行时才生成,重试路径将无法保证复用同一值。 + +### 4. 不做外部渠道实测 + +渠道适配按官方 SDK/接口契约实现(微信 v3 PowerWeChat、微信 v2 自研 XML、富友自研 XML/RSA、支付宝 `smartwalle/alipay/v3`)。Agent MUST NOT 调用任何真实支付渠道完成验证;本地验证使用受控适配器桩,覆盖明确成功、渠道明确拒绝、超时、结果未知、凭证失效、余额不足与查询恢复。真实渠道可退款性由维护者后续手工验证。文档只记录「按契约实现,未做外部渠道实测」,不列为阻塞、未完成任务或上线前置。 + +### 5. 审批尝试模型:新增 `tb_refund_request_attempt`,`business_id` 取尝试记录主键 + +沿用已确立的提现审批尝试模式(`tb_commission_withdrawal_request_attempt`,`migrations/000215`;消费侧 `internal/application/distributionwithdrawal/withdrawal_approval.go:32-74`)。 + +新表字段:`id`(主键,同时是审批 `business_id`)、`refund_id`、`attempt_no`、退款方式、退款金额、冻结实收金额、退款原因、客户收款信息、客户凭证键、套餐使用快照、渠道退款请求号、提交人账号、`approval_instance_id`、创建时间。约束:`UNIQUE(refund_id, attempt_no)`、`approval_instance_id` 部分唯一、`approval_instance_id IS NULL OR > 0`。 + +退款单追加(既有列与语义不变):`latest_attempt_id`、`latest_approval_instance_id`(仅展示,仿提现)、`method`、`frozen_actual_received_amount`、`customer_account_info`、`channel_refund_status`、`channel_refund_no`、`channel_refund_request_no`、`channel_failure_reason`、`anomaly_flag`、`anomaly_reason`。`approval_instance_id` 保持「首次接入企业微信审批的实例」语义,其既有唯一索引与本地人工终审的 `IS NULL` 保护全部不动。 + +`business_type` 保持 `refund_approval` 不变,因此 `tb_wecom_approval_scene` 的 `chk_wecom_approval_scene_business` 无需扩展,企微场景字段映射也不变。 + +**业务标识解析(实例优先、双读兼容)**: + +1. `tb_refund_request_attempt WHERE id = business_id AND approval_instance_id = instance_id` → 新路径; +2. `tb_refund_request WHERE id = business_id AND approval_instance_id = instance_id` → 存量兼容; +3. 两者均不匹配 → 稳定冲突错误,不得回落。 + +用 `approval_instance_id` 作为唯一判别式是必需的:尝试记录与退款单来自两个独立序列,必然存在同值,仅凭 `business_id` 无法区分。 + +**备选方案与取舍**:曾考虑直接为退款单开放多实例(放宽 `uq_approval_instance_business`),被否决——该约束是全部六个审批场景共享的不变量,放宽会波及已上线的核销、提现、分销与资格审批;尝试记录模式改动面局限在退款自身。 + +### 6. 状态集、活动集合与活动退款唯一索引 + +`RefundRequest.Status` 保持 int(ENG-STATE-001),沿用既有 1–4 语义并新增 5、6: + +| 值 | 常量名 | 名称 | 语义 | 可否重提 | 是否阻断同订单新申请 | +| --- | --- | --- | --- | --- | --- | +| 1 | `RefundStatusPending` | 待审批 | 企微审批在途 | 否 | 是 | +| 2 | `RefundStatusApproved` | 已通过 | 审批通过且退款已完成(原路须渠道明确成功) | 否 | 是 | +| 3 | `RefundStatusRejected` | 已拒绝 | 企微驳回、撤销或删除 | 是 | 否 | +| 4 | `RefundStatusReturned` | 已退回 | 本地退回,仅存量无实例申请可达 | 是 | 否 | +| 5 | `RefundStatusChannelProcessing` | 原路退款处理中 | 企微已通过,渠道结果未确认 | 否 | 是 | +| 6 | `RefundStatusChannelFailed` | 原路退款失败 | 渠道明确失败或超时保留的可恢复失败 | 是 | 是 | + +- **活动集合**(同订单互斥与唯一索引谓词):`{1, 5, 6}`,与 PRD 2.3.6 的「审批中、原路退款处理中、原路退款失败」一一对应。 +- **可重提集合**:`{3, 4, 6}` 且 `anomaly_flag = 0`。 +- 既有状态 2 的显示名保持「已通过」,不做破坏性改名;其新增语义为「退款已完成」。 +- `anomaly_flag ∈ {0,1}` 与状态正交(沿用提现异常标记形态),因此「企微通过后撤销」不改变状态,而是写异常标记并禁止重提。 +- 契约上原路退款在企微通过后、渠道确认前订单仍为已支付,订单支付状态不再能阻止第二张申请,因此活动退款唯一索引是必需的主守卫: + +```sql +CREATE UNIQUE INDEX uk_refund_request_active_order + ON tb_refund_request (order_id) + WHERE deleted_at IS NULL AND status IN (1, 5, 6); +``` + +迁移 MUST 在创建索引前探测活动集合内的重复 `order_id`,存在时明确失败并中止整次迁移,不自动改写历史数据。活动集合在迁移内写死并加注释说明与常量对应(迁移不引用 Go 常量)。 + +### 7. 权益时点:企微通过写可靠失效事实,最终一致执行 + +企微通过事务内只写可回滚的本地事实:退款申请终态、按方式确定的订单支付状态、原钱包退款回款、员工账单冲销、通知/佣金/资产三类 Outbox 与审计。套餐失效、接续下一套餐与停机评估保留既有 Outbox→Worker 路径(`refund.asset.process.requested` → `handleRefundAssetProcessing`),不在事务内执行运营商停机等外部调用(ENG-TX-001)。 + +**与原设计措辞的偏离**:原 `design.md` 与 spec 写作「企业微信通过事务内完成套餐失效/接续/停机」。照此实现会把运营商停机调用放进资金事务,违反 ENG-TX-001,且套餐失效本身已是可靠的最终一致流程,无需迁入事务。本 Change 改为契约层的「企微通过即写可靠失效事实」,对外可观察语义不变(企微通过后权益必然失效,渠道后续失败不回滚)。 + +### 8. 订单支付状态时点按退款方式确定 + +| 退款方式 | 置订单已退款的时点 | 说明 | +| --- | --- | --- | +| 客户收款信息 | 企微通过 | 退款单同时标记已通过,不等待线下付款 | +| 退回原钱包(资产钱包/代理主钱包) | 企微通过 | 回款在同一事务完成 | +| 原路退款 | 渠道明确成功 | 此前订单保持已支付,由活动退款唯一索引阻止第二张活动申请 | + +退款单一律按 PRD 2.3.2:仅渠道明确成功才标记已通过并保存渠道退款流水。既有实现的订单态更新位于企微通过事务(`approval_decision.go:90`),在原路方式下必须改为条件更新并在渠道成功回填路径执行;这也使既有 `changed` 门与重复投递幂等语义需要一并复核。 + +### 9. 本地人工终审与存量 + +保留既有 `Approval.LegacyRefundManualEnabled` 开关与入口,不新增任何运行时开关: + +- `Approve` 的条件更新补齐 `approval_instance_id IS NULL`(ENG-CONC-001 expected-status),使其与 `Reject`/`Return` 一致; +- 已关联审批实例的申请在三个入口一律拒绝; +- 存量无实例的待审批退款(测试库 4 条、生产 9 条)保持可终结路径,不强制接入企业微信; +- `Resubmit` 按审批尝试模式重写,不再原地改金额。 + +零金额语义保留:既有 `refund-approval` 能力要求接受零金额退款通过,且存量零金额待审批申请可经既有补发入口接入企业微信。新申请金额必须为正分,企微路径的批准金额等于申请金额,因此零金额只可能来自存量申请,其方式限客户收款信息,不产生钱包或渠道资金动作。 + +### 10. 结构化失败分类 + +`channel_failure_reason` 稳定枚举(`refund_failure_reason` 列,空表示无失败): + +| 值 | 名称 | 触发 | 判定为明确失败 | +| --- | --- | --- | --- | +| `channel_rejected` | 渠道明确拒绝 | 渠道返回拒绝或业务错误码 | 是 | +| `credential_invalid` | 凭证失效或缺失 | 商户退款必需凭证不完整或已失效 | 是 | +| `insufficient_balance` | 渠道余额不足 | 渠道返回余额不足 | 是 | +| `timeout_unknown` | 超时或结果未知 | 调用超时、网络错误或渠道仍处理中 | 否,保持可恢复 | +| `approval_rejected` | 企微驳回或关闭 | 企业微信驳回、撤销或删除 | 是 | +| `revoked_after_approved` | 企微通过后撤销 | 企业微信通过后撤销 | 否,转人工异常 | +| `payment_fact_invalid` | 原支付事实不可用 | 原支付单、实际商户、原渠道流水或可退金额校验不通过 | 是 | + +`payment_fact_invalid` 是超出既定六项分类的补充项,已裁决保留并与 `credential_invalid` 并列、语义不合并:`credential_invalid` 指该商户退款必需凭证缺失或失效(渠道侧会拒绝),`payment_fact_invalid` 指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过(本地即不可执行)。两者失败位置与处置不同,合并会丢失该区分;第 7 项据本分类判定可回溯性:标记为「是」的分类表示该退款已明确失败、可进入回溯判定,「否」表示仍在途或需人工处理。 + +渠道退款状态 `channel_refund_status`:`0` 未发起或不适用、`1` 处理中、`2` 明确成功、`3` 明确失败。纯钱包与客户收款信息方式固定为 `0`。 + +富友查询窗口超期(结果永久未知)不增设新状态:保留状态 5 并置 `anomaly_flag = 1`,`anomaly_reason` 记录「富友退款查询窗口已过」,转人工处理。 + +### 11. 恢复任务形态 + +新增 `refund:channel:recovery` 定时任务,复刻既有代理在线充值恢复形态(`cmd/worker/main.go:465`:`@every 1m`、`Unique`、`MaxRetry`、批次扫描、不重发资金动作)。任务只按 `channel_refund_request_no` 查询渠道并回填结果:明确成功→保存流水并把退款单置已通过、按方式置订单已退款;明确失败→写失败分类并置状态 6;仍未知→保持状态 5。富友仅在提交后 3 日内查询,超期转人工异常。 + +### 12. 与第 7 项(AUG26-012)的事实契约 + +本 Change 不实施佣金回溯明细,只固定并提供:退款完成事件键(保留既有 `refund.commission.deduct.requested` + `refund:` 兼容,需要区分原路成功与客户收款信息完成时新增独立事件);`refund_id` 与 `order_id`;成功退款金额(回溯比例分子);冻结实收金额(回溯比例分母);终态时点 `processed_at`;结构化失败分类(区分明确失败可回溯与在途不可回溯);幂等唯一键为「一次退款一次回溯」,不得依赖 `commission_deducted` 布尔(该标记在既有实现中可能被重复置位)。 + +现有实现的全额回扣(`deductAllCommission` 把原佣金置失效并扣佣金钱包)与 PRD 2.14 的「原佣金不变 + 另建负数明细 + 按比例回溯 + 新增回溯终态」存在差异,本 Change 不改变该行为。 + +### 13. 渠道退款结果通知:不传递、不接收、只查询 + +**决定(已裁决)**: + +- 不新增任何渠道退款结果通知路由; +- 不把退款通知指向既有支付回调 URL(既有回调只承载支付业务,退款通知会污染其幂等与状态判定); +- 微信 v3 退款请求**不传 `notify_url`**(渠道/商户/支付宝特有的通知字段一律不写入退款请求); +- 富友无退款通知能力,结果只经 `/refundQuery` 获取; +- 支付宝退款结果取调用同步响应; +- 退款结果的唯一确认路径为「渠道同步响应 + 主动查询 + 恢复任务」; +- 不修改 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`。 + +**理由**:新增通知入口需要注册路由并同步两处文档生成装配(ENG-ROUTE-001),且通知会与既有支付回调的验签、幂等和状态推进逻辑纠缠。退款终态本就要求「仅渠道明确成功才标记成功」,而同步响应与主动查询已能给出该结论;恢复任务本就定义为「只查询与回填」,因此通知路径是冗余的。代价是终态确认最坏延迟到一个恢复周期,可接受。 + +**已知约束(渠道侧未实测的前提)**:不传 `notify_url` 在渠道侧的行为未经真实调用验证。执行时按契约调用;若维护者后续实测发现渠道强制要求该字段,须新增受控通知入口并同步文档生成入口——属独立任务扩张,需另行确认。 + +## Risks / Trade-offs + +- **不传 `notify_url` 的渠道行为未经实测** → 若某渠道在实际环境中强制要求通知字段,退款调用可能被拒绝或结果只能靠查询收敛;缓解:按契约不传该字段,终态由同步响应与恢复查询确认,未实测仅作记录、不作为阻塞。 +- **[活动集合含状态 5/6] 与原路退款处理期间订单保持已支付组合后,唯一索引成为唯一守卫** → 索引谓词与 Go 常量漂移会导致并发重复申请;缓解:迁移内写死集合并加注释,测试断言集合与常量一致。 +- **富友 30/360 天与 3 日查询窗口是渠道硬约束** → 超期原交易无法原路、超期未知结果永久未知;缓解:创建/提交预检禁用超期原路,超期未知转人工异常,绝不重发。 +- **微信 v2 商户退款能力默认不可用** → 存量 v2 商户原路退款被禁用,可能被误判为功能缺失;缓解:在错误文案中说明凭证不完整,spec 已明确能力只由凭证完整性决定。 +- **尝试模式切换后存量审批终态可能失配** → 业务标识解析只凭 `business_id` 会因两个序列同值而误判;缓解:解析必须同时匹配 `approval_instance_id`,双读顺序与冲突失败已写入 spec。 +- **订单支付状态在原路方式下延后置位** → 期间订单显示已支付但企微已通过、套餐已失效;缓解:退款单状态 5 与渠道退款状态在列表/详情可见,spec 已把该期间的可观察行为写死。 +- **不做外部渠道实测** → 契实现正确性无渠道证据;缓解:按契约实现并记录,实际可退款性由维护者手工验证,不阻塞实施、验证或归档。 ## Migration Plan -新增成对迁移和活动退款约束,先部署兼容读写与恢复消费者,再关闭人工审批入口;隔离库验证方式矩阵、重复回调、渠道未知、失败重提、权益时点和 up/down/up。 \ No newline at end of file +1. 新增成对迁移(编号按实施开始时 `migrations/` 目录最大编号顺延,不预占):`tb_refund_request_attempt` 表、退款单列扩展、活动退款部分唯一索引、失败分类与渠道状态 CHECK。既有迁移与既有 `approval_instance_id` 约束不改;不把任何既有实例的 `business_id` 回填改写为尝试记录标识。 +2. 迁移前探测活动集合内重复 `order_id`,存在则明确失败中止。 +3. 先部署兼容读写的 API/Worker(尝试模式写入 + 业务标识双读 + 恢复任务注册),再实现渠道退款调用与回填;本地人工终审入口保持开启,行为仅收紧为「已关联实例一律拒绝」。 +4. down 迁移须在存在尝试记录或渠道退款事实时拒绝执行,避免丢失不可重建的资金事实。 +5. 验证面按 ENG-TEST-001:`junhong_cmp_test` PostgreSQL + Redis DB 6,本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh` 完成 up/down/up,只创建与删除本 Change 自有 fixture,禁止重置整库;渠道适配使用受控桩,不调用真实渠道。 diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md b/openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md index 8e0e354..7d02451 100644 --- a/openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md +++ b/openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md @@ -4,14 +4,16 @@ ## Why -现有退款以本地审批状态处理,不能按来源实际支付事实决定退款方式、冻结实收金额,或在企业微信通过后用原实际收款商户可靠执行原路退款。 +现有退款以本地审批状态处理,不能按来源实际支付事实决定退款方式、冻结实收金额,或在企业微信通过后用原实际收款商户可靠执行原路退款;退款单也无法按「每次提交持有独立审批实例」重提,且系统没有任何渠道原路退款调用。 ## What Changes -- 退款申请冻结来源订单、权威实收金额、唯一套餐使用情况、退款金额/原因、方式和当次审批材料;实收金额不可由提交人修改。 +- 退款申请冻结来源订单、权威实收金额、唯一套餐使用情况、退款金额/原因、方式和当次审批材料;实收金额由系统从原成功支付记录或订单实际收款派生,提交人不可填写或修改。 - 建立线上支付、资产钱包、代理主钱包和后台线下订单的退款方式矩阵,并在创建、提交和执行前重复校验。 -- 企业微信是唯一审批终审:通过即按既有规则失效套餐;客户收款信息退款即完成,原路退款须待渠道明确成功才完成。 -- 原路退款固定使用原支付单实际商户和渠道流水;超时/未知/失败保留可恢复状态,不重复退款;未成功申请可按规则修改并新建审批实例重提。 +- 企业微信是唯一审批终审;每次提交或重提新增一条不可变审批尝试记录与一个新的企业微信审批实例,历史材料不被覆盖;本地人工终审只保留既有存量终结路径,不新增任何运行时开关。 +- 企业微信通过即写可靠失效事实,使关联套餐失效、接续下一套餐并评估停机,由既有可靠机制最终一致执行;客户收款信息退款与退回原钱包在企微通过时完成,原路退款须待渠道明确成功才完成,且仅此时置订单已退款。 +- 按官方契约新增微信直连、富友和支付宝的原路退款适配,固定使用原支付单实际商户与其当前凭证;渠道能力只由服务商类型与退款必需凭证完整性决定,不提供人工开关;超时/未知/失败保留可恢复状态,不重复退款。 +- 一笔订单最多一张最终成功退款、同时至多一张活动退款申请,由活动退款唯一约束与条件状态更新共同保证。 ## Capabilities @@ -21,8 +23,10 @@ ### Modified Capabilities -- `order-refund-exchange`: 退款申请、状态机、方式矩阵和套餐联动。 +- `order-refund-exchange`: 退款申请、状态机、方式矩阵、原路渠道退款执行与套餐联动。 ## Impact -影响退款模型/接口、企业微信审批、支付商户与渠道退款、资产/代理钱包、员工账单和佣金回溯;需新增成对迁移并淘汰本地人工终审入口。 \ No newline at end of file +影响退款模型/接口、企业微信审批、支付商户与渠道退款(微信直连、富友、支付宝)、资产/代理钱包、员工账单和佣金回溯;需新增成对迁移。 + +渠道适配按官方 SDK/接口契约实现,本 Change 不调用任何真实支付渠道验证:本地验证使用受控适配器桩,「未做外部渠道实测」只作记录,不作为阻塞、未完成任务或上线前置,真实渠道可退款性由维护者后续手工验证。 diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md b/openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md new file mode 100644 index 0000000..7830cb2 --- /dev/null +++ b/openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md @@ -0,0 +1,92 @@ +## MODIFIED Requirements + +### Requirement: 商户与微信授权配置管理 + +系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat` 或 `alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。创建或更新商户时 `credentials` MUST 为扁平 JSON 对象,必填键由支付方式与服务商类型组合确定:`wechat/wechat` 为 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url`;`wechat/wechat_v2` 为 `wx_mch_id`、`wx_api_v2_key`、`wx_notify_url`,并可选 `wx_client_cert_content`、`wx_client_key_content`(API 客户端证书与私钥,为 v2 原路退款的双向证书所需);`wechat/fuiou` 为 `fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url`;`alipay/alipay` 为 `ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url`。凭证值 MUST 为字符串,仅可选的 `ali_production`(布尔)与 `ali_pay_expire_minutes`(整数)例外;`merchant_identity` MUST 分别等于 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id`。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数,C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。 + +超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。仅上述角色的专用管理列表和详情响应可返回完整凭证;日志、审计快照、错误、导出、支付快照和其他业务响应 MUST NOT 保存或返回敏感凭证。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和原路退款仍使用该商户当前凭证;原路退款的实际渠道调用由 `order-refund-exchange` 能力按该商户当前凭证执行,本能力 MUST NOT 保存渠道退款流水或推进退款状态。可退性只依据服务商类型与该服务商类型退款必需凭证的完整性判定,本能力 MUST NOT 提供人工退款能力开关。`wechat/wechat_v2` 的 `wx_client_cert_content` 与 `wx_client_key_content` 为可选键:不填不影响该商户的支付、查单与回调,只使原路退款按其凭证完整性判定为不可用。 + +#### Scenario: 受引用商户停用 + +- **WHEN** 管理员停用已被支付单命中的商户 +- **THEN** 新支付单不再选择该商户,已命中支付单的回调、查询和原路退款仍按该商户处理 + +#### Scenario: 非管理角色读取配置 + +- **WHEN** 不具备超级管理员或平台用户身份的账号请求商户或微信授权配置 +- **THEN** 系统拒绝访问且不返回任何凭证或身份字段 + +#### Scenario: 凭证轮换后处理历史支付 + +- **GIVEN** 已被支付单引用的商户或当前微信授权配置完成凭证更新 +- **WHEN** 更新提交后创建支付、处理回调、查单或原路退款 +- **THEN** 系统只使用更新后的当前有效凭证,不得继续使用更新前的缓存凭证 + +#### Scenario: 凭证键不完整或身份不一致被拒 + +- **WHEN** 管理员创建或更新商户时提交缺少该支付方式与服务商类型必填键的 `credentials`,或 `merchant_identity` 与凭证中的商户号或应用标识不一致 +- **THEN** 系统拒绝写入并返回参数错误,不创建或修改商户记录 + +#### Scenario: 商户凭证不足以执行退款 + +- **GIVEN** 已被支付单命中的商户缺少其服务商类型执行原路退款所需的凭证 +- **WHEN** 系统判定该支付单原路退款的可退性 +- **THEN** 判定结果为不可用且只允许客户收款信息退款,系统不因该结果新增商户凭证键或人工退款开关 + +### Requirement: 新支付商户快照与历史兼容 + +C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于原路退款路径的可退性判定,不提供人工开关;实际渠道退款调用与退款终态由 `order-refund-exchange` 能力实现,本能力 MUST NOT 发起渠道退款请求。 + +上线迁移在存在唯一当前生效综合支付配置时复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,微信授权字段完整时创建全局微信授权配置;不完整的支付方式不建池,授权字段不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据;新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker;三类后续新支付 MUST 立即只走商户池并冻结路由,无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径。 + +#### Scenario: 新支付冻结实际商户 + +- **WHEN** 客户以微信或支付宝创建覆盖范围内的新线上支付单 +- **THEN** 系统选择并冻结一个实际商户和商户池路由快照,并使用该商户的服务商凭证发起支付 + +#### Scenario: 迁移后的新支付只走商户池 + +- **GIVEN** 迁移完成且已部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker +- **WHEN** 客户创建覆盖范围内的后续新线上支付 +- **THEN** 系统立即经启用商户池选择并冻结实际商户;无可用池、成员或池已停用时明确失败,不得走旧综合支付配置创建 + +#### Scenario: 历史支付保留旧读取路径 + +- **GIVEN** 支付单 merchant ID 为空 +- **WHEN** 该支付经过回调、查单或原路退款路径 +- **THEN** 系统仅按其 `payment_config_id` 处理,不因当前商户池推断或改写其商户,直到独立 Change 按数据留存期删除旧读取路径 + +#### Scenario: 旧支付单回调 + +- **WHEN** 商户池切换后收到未带新商户快照的历史支付单回调 +- **THEN** 系统按既有综合支付配置兼容处理该历史单,不将其改挂到任何新商户 + +#### Scenario: 微信授权配置迁移缺失 + +- **GIVEN** 当前生效综合支付配置的微信授权字段不完整 +- **WHEN** 执行商户池配置迁移后访问 C 端微信登录、OpenID、JSSDK 或微信支付 AppID 功能 +- **THEN** 系统明确返回微信授权未配置,不得回退旧综合支付配置 + +#### Scenario: 多个当前生效综合支付配置 + +- **GIVEN** 存在多条当前生效综合支付配置 +- **WHEN** 执行商户池配置迁移 +- **THEN** 迁移失败且不选择任一配置作为复制来源 + +#### Scenario: 富友双读兼容基线 + +- **GIVEN** 富友 `CommonQuery` 未经第三方实测 +- **WHEN** 系统以商户当前凭证完成富友支付的本地双读配置来源和商户加载接线,且不改变现有 `CommonQuery` 请求格式、签名算法、验签、状态映射或恢复语义 +- **THEN** 系统保留该兼容基线;富友原路退款由 `order-refund-exchange` 能力按 `/commonRefund` 与 `/refundQuery` 契约单独实现,未第三方实测可以记录,但不得作为实施、验证或归档阻塞 + +#### Scenario: 维护者指定测试环境的配置迁移验证 + +- **GIVEN** 维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 与当前 Change fixture +- **WHEN** 本地工作区以明确 `DB_*` 完成 migration up/down/up 和数据行为验证后提交推送 Iteration/8-11 +- **THEN** Gitea 只构建/部署 `cmp-test` 测试镜像并检查迁移版本;测试日志保存在 `/opt/junhong_cmp/logs`,不得自动执行迁移或重置整库 + +#### Scenario: 历史支付路径保留 + +- **GIVEN** 存在 merchant ID 为空的历史支付 +- **WHEN** 商户池功能已上线且独立 Change 尚未按数据留存期删除旧读取路径 +- **THEN** 系统仍按该支付的 `payment_config_id` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户 diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md b/openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md index 74c8b83..0c6ef3e 100644 --- a/openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md +++ b/openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md @@ -1,32 +1,190 @@ +## MODIFIED Requirements + +### Requirement: 订单、退款与换货状态门禁 + +系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回、原路退款处理中、原路退款失败流转,仅已拒绝、已退回和原路退款失败申请可修改并重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。处于待审批、原路退款处理中或原路退款失败的退款申请 MUST 阻止同一订单创建新的退款申请,且 MUST NOT 被再次推进为新的审批实例或第二次渠道退款。 + +#### Scenario: 重复推进终态 + +- **GIVEN** 退款或换货已进入不允许当前操作的状态 +- **WHEN** 再次审批、发货、完成、取消或重新提交 +- **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态 + +#### Scenario: 原路退款失败后重提 + +- **WHEN** 退款申请处于原路退款失败状态且调用方在其数据范围内修改材料后重新提交 +- **THEN** 系统新增一条审批尝试记录与一个新的企业微信审批实例并使申请回到待审批,历史尝试与已提交的渠道退款不被覆盖 + +#### Scenario: 企业微信通过后撤销的申请被再次推进 + +- **GIVEN** 退款申请已记录企业微信通过后撤销的审批异常 +- **WHEN** 调用者再次提交或重提该申请 +- **THEN** 系统返回状态冲突,不恢复套餐权益、不取消已提交的渠道退款且不自动重提 + ## ADDED Requirements ### Requirement: 退款实收金额与方式矩阵 -系统 SHALL 从来源订单或原成功支付记录带出并冻结权威实收金额,提交人不得填写或修改;无法确定时拒绝申请。退款金额和企业微信授权金额不得超过该冻结额,且一笔订单最多一张最终成功退款申请,成功后不得再申请。 -线上微信/支付宝套餐订单仅可选原路退款或客户收款信息退款;资产钱包支付仅自动退回原资产钱包;代理预存款/主钱包支付仅自动退回原代理钱包;后台线下/员工代收套餐订单仅可选客户收款信息退款。代理充值预存款业务单不在本期退款范围。客户收款信息退款必须包含客户收款信息自由文本和客户凭证附件,且不得复用公司收款方式字典。 +系统 SHALL 从来源订单或原成功支付记录派生并冻结权威实收金额,提交人不得填写或修改;无法确定权威实收金额或该金额非正时拒绝创建申请。冻结来源为:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包和后台线下套餐订单取订单实际收款或实际扣款金额。退款金额 MUST 为正分且不得超过冻结实收金额,系统 MUST 在申请创建、审批提交和企业微信通过后的执行前重复校验该上限。一笔订单最多一张最终成功退款申请,成功后该订单不得再申请退款。代理充值预存款业务单不在本期退款范围。 + +可选方式按来源订单的实际支付方式确定,不匹配的方式返回「该订单不支持此退款方式」:线上微信、支付宝或富友支付仅可选原路退款或客户收款信息退款;资产钱包支付仅退回原资产钱包;代理预存款或主钱包支付仅退回原代理钱包;后台线下和员工代收套餐订单仅可选客户收款信息退款。系统 MUST NOT 为本能力新增人工退款方式开关。客户收款信息退款必须同时提供客户收款信息自由文本和至少一个客户凭证附件,且不得复用公司收款方式字典。 + +线上订单原路可退条件按渠道契约在创建、提交和执行前重复预检:冻结实际商户的退款必需凭证不完整、服务商类型不具备退款能力、或原交易超出渠道可退时限时禁用原路并说明原因,只允许客户收款信息退款。富友原交易的原始日期必须回传渠道;未回传时仅支持 30 天内原交易,回传后可退 360 天内原交易,超出该范围的申请不得选择原路。 #### Scenario: 无权威实收金额 -- **WHEN** 来源订单无法取得权威实收金额 + +- **WHEN** 来源订单无法取得权威实收金额,或取得的金额非正 - **THEN** 系统拒绝创建退款申请,不允许提交人以自填金额替代 +#### Scenario: 提交人试图修改冻结实收金额 + +- **WHEN** 创建或重提请求携带与派生值不同的实收金额 +- **THEN** 系统仍使用派生值作为冻结实收金额,不接受请求值 + #### Scenario: 钱包订单申请退款 + - **WHEN** 已支付套餐订单的实际支付方式为资产钱包或代理主钱包 - **THEN** 系统只提供退回对应原钱包方式,不展示原路或客户收款信息退款 -### Requirement: 企业微信审批和未成功重提 -退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地不得人工通过、拒绝或退回。 +#### Scenario: 原路凭证不完整 -同一订单同时至多存在一张审批中、原路处理中或原路失败申请。企业微信驳回、申请关闭或渠道明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次必须新建审批实例及快照。提交失败或审批结果未知保持在途,使用既有查询/恢复闭环,不得另建或重提。企业微信通过后撤销不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。 +- **GIVEN** 订单为线上支付且其冻结实际商户缺少该服务商类型退款必需凭证 +- **WHEN** 调用者申请退款 +- **THEN** 系统禁用原路退款并返回原因,只允许客户收款信息退款 + +#### Scenario: 富友原交易超出可退时限 + +- **WHEN** 富友原交易的原始支付时间早于可退时限 +- **THEN** 系统禁用原路退款并说明原因,只允许客户收款信息退款 + +### Requirement: 企业微信唯一终审与审批尝试重提 + +退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地 MUST NOT 人工通过、拒绝或退回已关联审批实例的申请。每次创建或重提 MUST 新增一条不可变的审批尝试记录,并以该尝试记录作为通用审批业务标识;同一申请每次提交各自持有独立审批实例,历史尝试材料与审批结果不被覆盖。退款单 SHALL 仅保存最新尝试与最新审批实例引用用于展示。 + +同一订单同时至多存在一张待审批、原路退款处理中或原路退款失败的退款申请。企业微信驳回、企业微信关闭、或原路退款明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次重提必须新建审批实例及快照。企业微信提交失败或审批结果未知时申请保持在途,使用既有查询与恢复闭环,不得另建或重提。企业微信通过后撤销时不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。 + +审批终态 MUST 以审批实例标识判别业务归属:先按审批尝试记录标识与审批实例匹配,未命中时按退款申请标识与审批实例匹配以兼容尚未切换到尝试模式的存量申请,两者均不匹配时返回稳定冲突错误,不得回落到任一候选业务单。 #### Scenario: 审批通过前结果未知 + - **WHEN** 企业微信提交成功性或最终结果暂时未知 - **THEN** 申请保持在途且订单不得创建第二张活动申请,系统通过既有恢复机制确认结果 -### Requirement: 原路退款执行与权益时点 -对线上订单,系统在创建、提交及企业微信通过后的执行前均 SHALL 校验原支付单、实际收款商户、渠道流水、可退金额及商户退款能力/凭证;商户停用不得阻断历史单校验。条件不满足时禁用原路并说明原因,只允许客户收款信息退款。 +#### Scenario: 未成功申请重提新建实例 -企业微信最终通过即按既有规则使关联套餐失效、接续下一套餐并评估停机;客户收款信息退款同时标记退款成功,不等待线下付款。原路退款必须以冻结金额调用原实际收款商户;仅渠道明确成功后标记成功并保存渠道退款流水。超时、未知、凭证失效、余额不足或渠道拒绝时保留处理中/失败与安全原因,不标记成功或重复调用;需改客户收款信息退款时必须修改后重新审批。 +- **GIVEN** 退款申请处于已拒绝、已退回或原路退款失败状态且无审批异常 +- **WHEN** 调用者修改材料后重新提交 +- **THEN** 系统在同一事务新增审批尝试记录并创建新的企业微信审批实例,原尝试记录与历史审批结果保持不变 + +#### Scenario: 存量申请审批终态仍被消费 + +- **GIVEN** 退款申请由本能力上线前创建,其审批实例的业务标识指向退款申请本身 +- **WHEN** 该审批实例到达通过或关闭终态 +- **THEN** 系统按审批实例标识匹配到该退款申请并幂等推进,不按尝试模式误判为不存在 + +#### Scenario: 已关联实例的本地人工终审 + +- **GIVEN** 退款申请已关联审批实例 +- **WHEN** 后台账号调用本地通过、拒绝或退回入口 +- **THEN** 系统拒绝该操作且不改变退款申请与订单状态 + +#### Scenario: 存量无实例待审批申请仍可终结 + +- **GIVEN** 退款申请未关联任何审批实例且处于待审批状态 +- **WHEN** 后台账号在既有开关开放时调用本地通过、拒绝或退回入口 +- **THEN** 系统按既有语义终结该申请,不要求该申请先接入企业微信审批 + +### Requirement: 原路退款渠道能力与执行 + +对线上订单,系统 SHALL 在创建、提交及企业微信通过后的执行前校验原支付单、实际收款商户、原渠道交易流水、可退金额和该商户退款必需凭证;商户停用不得阻断历史单校验。新支付按冻结实际商户标识加载该商户当前有效凭证,历史支付按原支付配置标识读取,不得按当前启用商户池推断历史商户。退款能力判定 MUST 只依据服务商类型与该服务商类型退款必需凭证的完整性,不提供人工退款能力开关:微信直连、富友和支付宝商户具备原路退款能力;微信 v2 商户的退款接口为双向证书接口,故其退款必需凭证含 API 客户端证书,缺少该证书时按其凭证完整性判定为不可用并只允许客户收款信息退款,补录证书后即可用。系统 MUST NOT 提供渠道降级开关。不可用时返回的错误说明 MUST 指明缺少哪一类凭证,避免被误判为功能未实现。 + +微信 v2 退款申请接口的返回仅代表渠道受理情况,MUST NOT 据此判定退款成功:受理成功时退款申请 MUST 保持原路退款处理中并等待退款查询确认,退款查询返回明确成功时才可标记已通过并保存渠道退款流水。 + +原路退款 MUST 使用冻结金额并调用原实际收款商户,实际收款商户不得改选。每次审批尝试 MUST 持久化一个稳定的记录级渠道退款请求号,提交时按冻结服务商类型的契约规则生成,同一尝试重试复用该值、重提生成新值;系统 MUST 以该请求号作为渠道幂等标识,至多提交一次可确认的退款请求。 + +仅渠道明确成功留存渠道退款流水后退款单才标记已通过;超时、结果未知、渠道明确拒绝、凭证失效、渠道余额不足或本地原支付事实不可用时不标记成功,写入结构化失败分类并保持原路退款处理中或失败状态。重申资金动作至多提交一次:系统 MUST 在提交前持久化认领该次渠道提交,只有认领成功的执行才可调用渠道退款接口;同一次尝试的重复投递或人工重放 MUST 只查询渠道结果并回填,MUST NOT 再次提交资金动作。 + +恢复任务 MUST 只查询渠道并回填结果,不得重复发起资金动作。本地查询窗口超期且结果仍未知时,系统 MUST 终止本次渠道执行并转人工核对:退款申请 SHALL 转为原路退款失败、渠道退款状态 SHALL 转为已失败、失败分类 SHALL 写为超时或结果未知,且此后 MUST NOT 再查询该渠道。超期 MUST NOT 标记为渠道明确成功。窗口超期后系统 MUST NOT 自动重提该申请(本次渠道请求可能已被受理,重提会以新请求号再次提交资金动作),也 MUST NOT 解除同一订单的活动退款互斥。超期边界为:富友因其契约只支持 3 日内查询而在 72 小时后超期;微信 v2 的受理响应不含退款状态且渠道无查询时限,其本地确认上限为 7 天。需改为客户收款信息退款时必须修改申请材料并重新走企业微信审批。 #### Scenario: 原路渠道调用未知 + - **WHEN** 企业微信已通过的原路退款调用超时且无法确认渠道结果 -- **THEN** 退款保持原路处理中或失败恢复状态,套餐权益不恢复,系统不得再次盲目提交退款 +- **THEN** 退款保持原路退款处理中,套餐权益不恢复,系统不得再次提交退款,仅由查询恢复回填结果 + +#### Scenario: 渠道明确成功 + +- **WHEN** 渠道返回明确退款成功 +- **THEN** 系统保存渠道退款流水号与渠道退款金额快照并把退款申请标记为已通过,同时按方式置订单已退款 + +#### Scenario: 富友退款查询窗口已过且结果未知 + +- **GIVEN** 富友原路退款已提交但结果未确认,且已超出其退款查询窗口 +- **WHEN** 恢复任务再次处理该退款 +- **THEN** 系统不再发起退款调用,保留办理中并记录审批异常供人工核对 + +#### Scenario: 商户已停用的历史原路退款 + +- **GIVEN** 原支付单命中的实际商户已被停用 +- **WHEN** 该订单执行原路退款 +- **THEN** 系统仍按该商户当前凭证调用渠道,商户停用本身不阻断调用 + +#### Scenario: 微信 v2 补录证书后原路可用 + +- **GIVEN** 订单原支付单命中的商户服务商类型为微信 v2,且其凭证补录了 API 客户端证书与私钥 +- **WHEN** 调用者申请原路退款 +- **THEN** 系统判定该商户原路退款可用,并以商户退款单号作为渠道路径的幂等标识发起退款 + +#### Scenario: 微信 v2 退款申请仅代表渠道受理 + +- **GIVEN** 微信 v2 商户凭证完整且企业微信已通过原路退款 +- **WHEN** 渠道退款申请接口返回受理成功但未返回退款状态 +- **THEN** 系统保持退款申请为原路退款处理中且订单保持已支付,只由退款查询确认最终结果 + +#### Scenario: 微信 v2 商户缺少客户端证书 + +- **GIVEN** 订单原支付单命中的商户服务商类型为微信 v2,且其凭证只有 APIv2 密钥 +- **WHEN** 调用者申请退款 +- **THEN** 系统禁用原路退款并说明缺少 API 客户端证书,只允许客户收款信息退款,且不阻断该商户的支付与查单 + +#### Scenario: 渠道退款结果不经通知接收 + +- **WHEN** 系统发起原路退款请求 +- **THEN** 系统不传递任何渠道退款结果通知地址、不新增退款通知路由,退款终态只由渠道同步响应、主动查询与恢复任务确认 + +### Requirement: 退款权益与订单状态时点 + +企业微信最终通过时,系统 SHALL 在同一事务内写退款申请终态、按方式确定的订单支付状态、原钱包退款回款、员工代收账单冲销和可靠套餐失效事实;企业微信通过后的撤销不回滚上述任何已成立事实。套餐失效、接续下一套餐和停机评估 MUST 由既有可靠机制最终一致执行,不得在资金事务内执行运营商停机等不可回滚的外部调用。 + +订单支付状态时点按退款方式确定:客户收款信息退款与退回原钱包在企微通过时置为已退款;原路退款仅在渠道明确成功时置为已退款,此前订单保持已支付。原路退款在企微通过后、渠道结果确认前的期间内,同一订单 MUST 由活动退款唯一约束阻止产生第二张活动退款申请。重复或乱序的企业微信终态不得再次失效套餐、再次回款或启动第二次渠道退款。 + +#### Scenario: 企微通过后的权益时点 + +- **WHEN** 退款申请到达企业微信最终通过 +- **THEN** 系统在同一事务写退款终态与可靠失效事实,套餐失效、接续与停机由既有可靠机制随后完成 + +#### Scenario: 原路退款在渠道确认前置订单状态 + +- **GIVEN** 退款方式为原路退款且企业微信已通过 +- **WHEN** 渠道结果尚未确认 +- **THEN** 订单保持已支付、套餐权益已按企微通过失效,且该订单不得创建新的退款申请 + +#### Scenario: 渠道最终失败不回滚权益 + +- **WHEN** 原路退款渠道明确失败 +- **THEN** 退款申请标记原路退款失败且不标记已通过,已失效套餐权益不恢复,订单保持已支付 + +### Requirement: 退款终态事实与失败分类 + +退款申请 SHALL 保存结构化失败分类、渠道退款状态、渠道退款流水与渠道退款请求号,并在列表、详情和导出中返回冻结实收金额、方式、申请状态、渠道退款状态、失败安全摘要、审批尝试历史和渠道流水,按既有订单数据范围过滤。失败分类 MUST 为稳定枚举,至少覆盖:渠道明确拒绝、渠道凭证失效、渠道余额不足、超时或结果未知、企业微信驳回或关闭、企业微信通过后撤销,以及本地原支付事实不可用。渠道凭证失效与本地原支付事实不可用 MUST 为两个并列分类、语义不得合并:前者指该商户退款必需凭证缺失或失效,后者指本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。每个分类 MUST 显式标记其是否属于「明确失败」:明确失败表示退款已终结且可进入后续回溯判定,非明确失败表示仍在途或需人工处理。审计 SHALL 记录申请、重提、审批终态、权益处理、渠道调用与恢复,且不得记录凭证内容、完整收款文本或商户密钥。 + +为后续佣金回溯能力提供稳定事实,退款终态 SHALL 可按退款单与订单定位,并提供:成功退款金额、冻结实收金额、终态时点、结构化失败分类及其明确失败标记,以及区分原路成功与客户收款信息完成的完成事件键。后续回溯判定 MUST 依据上述分类标记而非猜测文本:标记为明确失败的退款才可进入回溯判定,标记为非明确失败的退款(超时或结果未知、企业微信通过后撤销)保持在途或转人工,不得回溯。系统 MUST 保留既有退款佣金回扣事件键的兼容语义;佣金回溯的幂等键为一次退款一次回溯,不依赖退款单上的佣金回扣标记。 + +#### Scenario: 渠道失败分类可查询 + +- **WHEN** 原路退款因渠道余额不足失败 +- **THEN** 退款详情返回该失败分类与安全摘要,且不返回任何凭证内容或完整收款文本 + +#### Scenario: 审计不含敏感内容 + +- **WHEN** 渠道退款调用或恢复完成后写入审计 +- **THEN** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文 diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md b/openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md index e590bfd..4cfb773 100644 --- a/openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md +++ b/openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md @@ -1,15 +1,18 @@ ## 1. 退款契约与数据 -- [ ] 1.1 追踪退款、订单/支付、钱包、套餐、企微审批和商户退款调用链;新增成对迁移、模型、状态/方式常量、实收/材料/渠道结果快照及活动申请唯一约束。 -- [ ] 1.2 实现退款可选方式和权威实收金额投影,创建/提交时冻结金额、套餐使用情况、原因和材料;更新 DTO/OpenAPI。 +- [x] 1.1 追踪退款、订单/支付、钱包、套餐、企微审批与商户凭证调用链,形成文件/符号级实现清单;新增成对迁移(编号按实施开始时 `migrations/` 目录最大编号顺延,不预占):`tb_refund_request_attempt` 表(`id` 为审批业务标识、`UNIQUE(refund_id, attempt_no)`、`approval_instance_id` 部分唯一)、退款单列扩展(最新尝试/最新实例引用、方式、冻结实收、客户收款信息、渠道退款状态/流水/请求号、结构化失败分类、异常标记与原因)、活动退款部分唯一索引 `(order_id) WHERE deleted_at IS NULL AND status IN (1,5,6)`、失败分类与渠道状态 CHECK。索引创建前探测活动集合内重复 `order_id`,存在则明确失败中止整次迁移,不自动改写历史数据;活动集合在迁移内写死并加注释说明与常量对应。不修改既有迁移,不修改既有 `approval_instance_id` 语义或其唯一约束,**不把任何既有审批实例的 `business_id` 回填改写为尝试记录标识**。证据:`migrations/000218_add_refund_attempt_and_channel_refund.{up,down}.sql`;测试库 `junhong_cmp_test` 完成 up→down→up 往返,`schema_migrations` 版本 218 且 `dirty=false`;down 在存在尝试记录或渠道退款事实时拒绝执行(探测语句实测 0 条时正常回滚)。 +- [x] 1.2 实现退款可选方式与权威实收金额投影:线上支付取原成功支付记录金额,资产钱包/代理主钱包/后台线下取订单实际收款或实际扣款;缺失或非正时拒绝创建;提交人传入的实收金额一律忽略,不可填写或修改。按来源实际支付方式生成可选方式矩阵,并在创建、提交与执行前三次校验「退款金额为正分且不超过冻结实收」。线上原路可退条件预检(冻结商户退款必需凭证完整性、服务商类型具备退款能力、原交易未超渠道可退时限;富友按是否回传原交易日期判定 30 天或 360 天),不满足时禁用原路并说明原因。同步更新 `RefundRequest`/DTO/OpenAPI:新增方式、冻结实收、客户收款信息、渠道退款状态与流水、失败分类、异常标记与审批尝试历史;扩展 `RefundListRequest.Status` 上限与状态名称映射,枚举描述与 `pkg/constants` 逐值一致(ENG-DTO-001)。不提供任何退款方式或渠道能力的人工开关。证据:`internal/service/refund/method.go`、`internal/model/refund.go`、`internal/model/dto/refund_dto.go`、`internal/exporter/refund_scene.go`、`pkg/constants/refund.go`、`pkg/constants/iot.go`;烟测实测线上订单冻结实收=支付记录金额 12345、原路与客户收款信息均可用、超限与零金额被拒、方式不匹配被拒、客户收款信息材料缺失被拒。 -## 2. 审批、资金与恢复 +## 2. 审批、资金与权益 -- [ ] 2.1 将退款终审切换为企业微信回调/查询恢复,移除新业务的本地人工通过/拒绝/退回;实现未成功申请的新实例重提。 -- [ ] 2.2 在企微通过事务内执行套餐失效/接续及原钱包退款;客户收款信息退款直接完成。 -- [ ] 2.3 实现原实际商户、渠道流水和退款能力三次校验、可靠原路退款调用、幂等回写与未知/失败恢复;联动员工账单冲销和佣金回溯入口。 +- [x] 2.1 审批尝试写入与业务标识双读:创建与重提在同一事务内新增不可变审批尝试记录、创建新的企业微信审批实例、回写尝试的审批实例引用并更新退款单最新引用(仅展示);`business_type` 保持 `refund_approval`,因此 `tb_wecom_approval_scene` 的 CHECK 不扩展。审批终态消费按「尝试记录 + 审批实例」优先、「退款申请 + 审批实例」兜底解析业务归属,两者均不匹配时返回稳定冲突错误。同步改造全部注册点:审批终态消费者、审批业务审计资源构造、审计查询的退款/审批关联与店铺过滤子查询、审批业务跳转资源映射、最新审批状态批量投影。其他审批场景(线下代充值、员工代收核销、分销注册、提现资格、佣金提现)语义不变,`cmd/worker` 业务类型分发注册表键不变。 证据:`internal/application/refundapproval/creation.go`(Execute/Resubmit/TriggerHistorical 同事务写尝试记录与实例)、`internal/application/refundapproval/resolve.go`(ResolveRefundInTx 实例优先双读 + ResolveRefundForApprovalRequestInTx 承认在途未回写形态)、`internal/service/refund/approval_decision.go`、`internal/infrastructure/audit/approval.go`、`internal/query/audit/finance.go`、`internal/service/refund/attempt_query.go`;烟测实测「尝试模式解析」与「存量兼容解析(attempt 为 nil)」均通过,实例不匹配返回稳定冲突错误。 +- [x] 2.2 本地人工终审与存量:保留既有 `legacy_refund_manual_enabled` 开关与三个入口,不新增运行时开关;为通过入口补齐 `approval_instance_id IS NULL` 条件更新守卫,使三个入口一致拒绝已关联审批实例的申请;存量无实例的待审批退款保持可终结路径;重提入口按审批尝试模式重写(仅已拒绝、已退回、原路退款失败且无审批异常可重提),不再原地修改金额沿用旧实例。 证据:`internal/service/refund/service.go`(Approve 条件更新补齐 `approval_instance_id IS NULL` 并与 Reject/Return 一致;Resubmit 改为经 `refundapproval.Resubmit` 新建尝试与实例;resubmitPrecheck 覆盖已拒绝/已退回/原路失败且异常标记为 0);开关与存量路径未改动。 +- [x] 2.3 企微通过事务:写退款申请终态;按退款方式置订单支付状态(客户收款信息与退回原钱包在企微通过时置已退款,原路不在此置位);执行原钱包退款回款;保留员工代收账单冲销的既有调用与顺序;写通知、佣金回扣与资产后处理可靠事件;写审计。套餐失效、接续下一套餐与停机评估继续由既有可靠机制在事务外最终一致执行,不得把运营商停机等外部调用放入资金事务。重复或乱序终态不得再次失效套餐、再次回款或启动第二次渠道退款;企业微信通过后撤销不改变退款状态、不恢复权益、不取消已提交的渠道退款,只写异常标记并禁止自动重提。 证据:`internal/service/refund/approval_decision.go`(原路方式置状态 5 并只登记待执行事实与可靠事件,订单在渠道确认前保持已支付;非原路方式在企微通过时置订单已退款;员工账单冲销调用与顺序未变;套餐失效/接续/停机仍由事务外 Outbox 最终一致执行;applyRevokedAfterApproved 只写异常标记并禁止重提)。 +- [x] 2.4 渠道原路退款适配与调用:按官方契约实现微信直连(PowerWeChat v3;v2 走双向证书接口 `/secapi/pay/refund` 并新增 `wx_client_cert_content`/`wx_client_key_content` 两个可选凭证键,受理成功按结果未知交由退款查询确认)、富友(申请退款 `POST /commonRefund`,必填 `version`/`ins_cd`/`mchnt_cd`/`term_id`/`mchnt_order_no`/`random_str`/`sign`/`order_type`/`refund_order_no`/`total_amt`/`refund_amt`,取 `refund_id`/`transaction_id`/`reserved_refund_amt`/`reserved_fy_settle_dt`,并回传原交易日期;查询 `POST /refundQuery`,入参 `refund_order_no`,`trans_stat` 为 `SUCCESS`/`PAYERROR`)、支付宝(`smartwalle/alipay/v3` 退款与退款查询)。能力判定只依据服务商类型与退款必需凭证完整性,不提供人工开关;富友凭证键集合不新增。每次审批尝试在提交时生成并持久化按富友规则(机构码 4 位 + 上海时区 `yyyyMMdd` + 8–18 位字母数字)的渠道退款请求号,同一尝试重试复用、重提生成新值;提交前先以 `channel_submitted_at IS NULL` 条件认领提交权,只有认领成功的执行才调用渠道退款接口,重复投递或人工重放只查询渠道结果并回填、绝不二次提交;以原支付单、冻结实际商户当前凭证、原渠道流水与可退金额执行校验后,用该请求号至多提交一次可确认的退款请求(富友 `mchnt_order_no` 取原支付单商户订单号,`order_type` 取原交易值)。明确成功时保存渠道退款流水与渠道退款金额快照、置退款已通过并置订单已退款;超时、未知、渠道拒绝、凭证失效、渠道余额不足或本地原支付事实不可用时写结构化失败分类并保持原路处理中或失败状态,不标记成功、不重复调用。渠道适配按契约实现,Agent 不调用真实渠道。 证据:`pkg/wechat/refund.go`、`pkg/wechat/payment_v2_refund.go`、`pkg/fuiou/refund.go`、`pkg/alipay/refund.go`、`migrations/000219_add_wechat_v2_client_cert_credentials.{up,down}.sql`、`internal/infrastructure/payment/refund_adapter.go`、`internal/application/refundchannel/{service,number,event,audit}.go`;`refundchannel.RefundCredentialIssue` 是退款能力唯一判定入口,申请预检与执行校验共用它;微信 v2 凭 API 客户端证书可用、缺证书时按凭证不完整判定不可用;v2 受理成功映射为结果未知、只由退款查询收敛;不向任何渠道传递通知地址。烟测以受控桩实测:明确成功回写渠道流水并置订单已退款、结果未知保持处理中且订单不动、重复执行不重复调用渠道。 +- [x] 2.5 渠道退款恢复任务:新增 `refund:channel:recovery`(复刻既有代理在线充值恢复的 `@every 1m`、`Unique`、`MaxRetry`、批次扫描形态),只按持久化的渠道退款请求号查询并回填结果,不重复发起资金动作;本地查询窗口超期即终止本次渠道执行并转人工:富友 72 小时(其查询接口只支持 3 日)、微信 v2 7 天(本地确认上限,渠道无查询时限);超期转原路退款失败、渠道状态转已失败、分类写超时未知并置异常标记,此后不再查询渠道且不放行自动重提(重提存在重复退款风险,由人工先向渠道核对)。窗口起算点不被终止标记或未知回写重置。定时任务注册与 Asynq 调度同步。 证据:`internal/application/refundchannel/recover.go`(只 Query、富友 72 小时窗口超期转异常标记)、`internal/infrastructure/payment/refund_channel_recovery_task.go`、`cmd/worker/main.go`(`refund:channel:recovery` 注册与 `@every 1m` + `Unique` + `MaxRetry` 调度;恢复与执行复用同一用例实例,使恢复确认的成功同样补写退款完成通知)。烟测实测恢复扫描 `Stats{Scanned:1 Confirmed:1}` 且 `Refund` 调用次数未增加。 +- [x] 2.6 退款终态事实契约(不实施 AUG26-012):固定并输出退款完成事件键(保留既有 `refund.commission.deduct.requested` 与 `refund:` 兼容,需要区分原路成功与客户收款信息完成时新增独立事件)、`refund_id`/`order_id`、成功退款金额(回溯比例分子)、冻结实收金额(回溯比例分母)、终态时点、结构化失败分类,以及「一次退款一次回溯」的幂等唯一键;明确不得依赖 `commission_deducted` 布尔。不改变既有全额佣金回扣行为。 证据:`pkg/constants/refund.go`(失败分类及 `RefundFailureReasonIsDefinitive` 明确失败标记)、`internal/model/refund.go`(`processed_at` 终态时点、`FrozenActualReceivedAmount` 回溯分母)、`internal/infrastructure/commissiondelivery/event.go`(既有 `refund.commission.deduct.requested` + `refund:` 事件键保留);未改变既有全额佣金回扣行为,未实施 AUG26-012。 ## 3. 验证 -- [ ] 3.1 在隔离库验证每种来源方式、实收上限、活动申请互斥、审批重放/未知、渠道失败重提、商户停用历史退款和套餐权益时点。 -- [ ] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-refund-methods-and-original-route-refunds --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 \ No newline at end of file +- [x] 3.1 按 ENG-TEST-001 在维护者指定的测试面验证:`junhong_cmp_test` PostgreSQL + Redis DB 6,迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh` 完成 up/down/up,仅创建与删除本 Change 自有 fixture,禁止重置整库。渠道侧不调用任何真实支付渠道,使用受控适配器桩覆盖明确成功、渠道明确拒绝、超时、结果未知、凭证失效、余额不足与查询恢复。人工核对:方式矩阵与实收金额派生及上限、提交人改金额被忽略、活动退款唯一索引(含原路处理中订单仍为已支付的并发场景)、审批尝试双读(新尝试路径与存量无实例/已关联实例路径)、重提新建尝试与实例且历史不被覆盖、本地人工终审对已关联实例一律拒绝而存量仍可终结、企微通过后的权益时点与重复/通过后撤销终态、订单状态按方式置位、渠道请求号重试复用与重提更换、提交认领保护(重复投递只查询不二次提交)、恢复任务不重发、富友 30/360 天预检与 3 日查询窗口超期转人工、微信 v2 7 天本地确认上限超期转人工,以及迁移 down 在存在尝试记录或渠道退款事实时拒绝执行。 证据:测试库 `junhong_cmp_test` 完成迁移 up→down→up(版本 218,`dirty=false`);烟测以 SMOKE 命名空间 fixture 实测方式矩阵与实收派生、金额上限与零金额拒绝、方式不匹配与客户收款信息材料校验、活动退款唯一索引(status 1/5/6 均拒绝同订单第二张、已拒绝状态放行)、审批尝试双读与实例不匹配冲突、渠道成功/未知/恢复三条路径与重复执行幂等;fixture 全部清理(残留计数为 0),既有 43 条退款单未被改动,未重置整库。真实支付渠道全程未调用。 +- [x] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-refund-methods-and-original-route-refunds --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。外部渠道未实测只作记录,不作为阻塞、未完成任务或上线前置。 证据:`gofmt -l`(变更集)无输出;`go build ./cmd/api ./cmd/worker` 退出码 0;`go run cmd/gendocs/main.go` 退出码 0 并生成 `docs/admin-openapi.yaml`;`openspec validate add-refund-methods-and-original-route-refunds --strict` 有效;`openspec validate --all` 38/38 通过;`openspec doctor --json` `healthy=true`。`./scripts/context-health.sh` 仍报 `Requirement 证据链与 Specs 不一致`:该失败为既有漂移(`agent-distribution-withdrawal` 5 项与 `personal-customer` 1 项 Requirement 缺证据行),与本 Change 无关,未由本 Change 修复;本 Change 新增的异步入口 `constants.TaskTypeRefundChannelRecovery` 已补入 `docs/verification/context-reset/entry-capability-requirement-matrix.json`,异步入口覆盖已双向一致。 diff --git a/openspec/specs/agent-distribution-withdrawal/spec.md b/openspec/specs/agent-distribution-withdrawal/spec.md index 5c14264..56af14c 100644 --- a/openspec/specs/agent-distribution-withdrawal/spec.md +++ b/openspec/specs/agent-distribution-withdrawal/spec.md @@ -155,3 +155,19 @@ - **WHEN** 同一未结算尝试的释放被重复执行 - **THEN** 系统至多释放一次,冻结余额与可提现余额不被重复调整 +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### 代理分销注册 + +`POST /api/c/v1/agent-distribution-registrations`(代理扫码注册,公开接口)。 + +### 提现资料资格 + +`POST /api/admin/shops/{shop_id}/withdrawal-qualifications`(提交提现资料资格);`GET /api/admin/shops/{shop_id}/withdrawal-qualifications`(查询提现资料资格版本);`POST /api/admin/withdrawal-qualifications/{id}/void`(作废提现资料资格)。 + +### 佣金提现 + +`GET /api/admin/shops/{shop_id}/withdrawal-requests`(代理商提现记录);`POST /api/admin/shops/{shop_id}/withdrawal-requests`(发起提现申请);`PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}`(重提被驳回的提现申请);`GET /api/admin/shops/{shop_id}/withdrawal-requests/{id}`(提现申请详情);`POST /api/admin/commission/withdrawal-requests/{id}/approve`(本地人工通过提现,仅存量无审批实例申请);`POST /api/admin/commission/withdrawal-requests/{id}/reject`(本地人工驳回提现,仅存量无审批实例申请)。 + diff --git a/pkg/alipay/refund.go b/pkg/alipay/refund.go new file mode 100644 index 0000000..dd08b85 --- /dev/null +++ b/pkg/alipay/refund.go @@ -0,0 +1,185 @@ +package alipay + +import ( + "context" + "fmt" + + "github.com/smartwalle/alipay/v3" + + "github.com/break/junhong_cmp_fiber/internal/model" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RefundStatusSuccess 支付宝退款查询接口(alipay.trade.fastpay.refund.query) +// 响应字段 refund_status 中表示「退款处理成功」的取值。 +const RefundStatusSuccess = "REFUND_SUCCESS" + +// RefundRequest 支付宝退款请求参数。 +// OutTradeNo 与 TradeNo 二选一;OutRequestNo 标识一次退款请求,同一笔交易多次退款必须保证唯一。 +type RefundRequest struct { + OutTradeNo string // 商户订单号,与 TradeNo 二选一 + TradeNo string // 支付宝交易号,与 OutTradeNo 二选一 + OutRequestNo string // 商户退款请求号,必填 + RefundAmount int64 // 退款金额,单位:分 + RefundReason string // 退款原因说明,可选 + TotalAmount int64 // 订单总金额,单位:分,可选(alipay.trade.refund 不接收该字段,仅供调用方上下文使用) +} + +// RefundResult 支付宝退款、退款查询的统一结果。 +// RefundFee、字段金额单位均为分;Success 表示渠道业务处理成功。 +type RefundResult struct { + Success bool // 渠道业务是否成功 + TradeNo string // 支付宝交易号 + OutTradeNo string // 商户订单号 + OutRequestNo string // 商户退款请求号 + RefundFee int64 // 退款金额,单位:分 + RefundStatus string // 渠道返回的退款状态原文,可能为空 + Message string // 渠道返回的失败原因,成功时为空 +} + +// Refund 发起支付宝退款(alipay.trade.refund)。 +// RefundAmount(分)按金额精度要求转换为元字符串(两位小数)后提交。 +// 渠道业务失败时返回 Success=false 与渠道 Message,不返回 error(渠道拒绝不是传输错误); +// 传输失败、响应不可解析等 SDK 层错误才返回 error,此时 result 为 nil, +// 调用方因无法确认退款结果,应通过 QueryRefund 查询退款状态。 +// TradeRefundRsp 不返回 out_request_no,故 OutRequestNo 以入参为准; +// 该响应亦无 refund_status 字段,需要退款状态原文时使用 QueryRefund。 +func Refund(ctx context.Context, cfg *model.WechatConfig, req RefundRequest) (*RefundResult, error) { + client, err := NewClientFromConfig(cfg) + if err != nil { + return nil, err + } + + param := alipay.TradeRefund{ + OutTradeNo: req.OutTradeNo, + TradeNo: req.TradeNo, + OutRequestNo: req.OutRequestNo, + RefundAmount: FenToYuan(req.RefundAmount), + RefundReason: req.RefundReason, + } + + rsp, err := client.TradeRefund(ctx, param) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款请求失败") + } + + result := &RefundResult{ + TradeNo: preferChannel(rsp.TradeNo, req.TradeNo), + OutTradeNo: preferChannel(rsp.OutTradeNo, req.OutTradeNo), + OutRequestNo: req.OutRequestNo, + } + + if rsp.IsFailure() { + result.Message = channelMessage(rsp.Error) + return result, nil + } + + refundFee, err := parseChannelAmount(rsp.RefundFee) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款响应不可解析") + } + + result.Success = true + result.RefundFee = refundFee + return result, nil +} + +// QueryRefund 查询支付宝退款(alipay.trade.fastpay.refund.query)。 +// 以 OutRequestNo 定位一次退款请求,映射渠道返回的退款金额与退款状态。 +// 仅当渠道业务码成功且 refund_status 为 REFUND_SUCCESS 时 Success=true; +// 渠道拒绝、refund_status 非成功(未返回该字段表示退款请求未收到或退款失败)时 +// 返回 Success=false 与渠道 Message/RefundStatus 原文,不返回 error; +// 传输失败、响应不可解析等 SDK 层错误才返回 error,此时 result 为 nil。 +func QueryRefund(ctx context.Context, cfg *model.WechatConfig, req RefundRequest) (*RefundResult, error) { + client, err := NewClientFromConfig(cfg) + if err != nil { + return nil, err + } + + param := alipay.TradeFastPayRefundQuery{ + OutTradeNo: req.OutTradeNo, + TradeNo: req.TradeNo, + OutRequestNo: req.OutRequestNo, + } + + rsp, err := client.TradeFastPayRefundQuery(ctx, param) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款查询请求失败") + } + + result := &RefundResult{ + TradeNo: preferChannel(rsp.TradeNo, req.TradeNo), + OutTradeNo: preferChannel(rsp.OutTradeNo, req.OutTradeNo), + OutRequestNo: preferChannel(rsp.OutRequestNo, req.OutRequestNo), + RefundStatus: rsp.RefundStatus, + } + + if rsp.IsFailure() { + result.Message = channelMessage(rsp.Error) + return result, nil + } + + refundFee, err := parseChannelAmount(rsp.RefundAmount) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款查询响应不可解析") + } + result.RefundFee = refundFee + + if rsp.RefundStatus != RefundStatusSuccess { + result.Message = "支付宝退款状态非成功" + if rsp.RefundStatus != "" { + result.Message += ": " + rsp.RefundStatus + } + return result, nil + } + + result.Success = true + return result, nil +} + +// preferChannel 渠道返回值优先,渠道未返回时回退到请求值。 +func preferChannel(channelValue, requestValue string) string { + if channelValue != "" { + return channelValue + } + return requestValue +} + +// channelMessage 拼接支付宝返回的错误信息,格式为「错误码/子错误码: 错误描述」,忽略空字段。 +func channelMessage(e alipay.Error) string { + code := string(e.Code) + if e.SubCode != "" { + if code == "" { + code = e.SubCode + } else { + code += "/" + e.SubCode + } + } + + detail := e.SubMsg + if detail == "" { + detail = e.Msg + } + + switch { + case code == "": + return detail + case detail == "": + return code + default: + return code + ": " + detail + } +} + +// parseChannelAmount 将渠道返回的元字符串(如 "1.00")精确转换为分。 +// 渠道未返回该字段(空字符串)时按 0 处理,不丢精度。 +func parseChannelAmount(yuan string) (int64, error) { + if yuan == "" { + return 0, nil + } + fen, err := YuanToFen(yuan) + if err != nil { + return 0, fmt.Errorf("渠道返回金额 %q 无法解析: %w", yuan, err) + } + return fen, nil +} diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index 02a0eaa..e8f68aa 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -361,6 +361,18 @@ const ( AuditActionRefundCommissionInvalidated = "refund.invalidate_commission" // AuditActionRefundAssetProcessed 表示退款后的套餐与资产处理已完成。 AuditActionRefundAssetProcessed = "refund.process_asset" + // AuditActionRefundAttemptSubmitted 表示提交或重提退款审批尝试并创建独立审批实例。 + AuditActionRefundAttemptSubmitted = "refund.attempt_submit" + // AuditActionRefundAttemptApproved 表示企业微信终审通过退款审批尝试。 + AuditActionRefundAttemptApproved = "refund.attempt_approve" + // AuditActionRefundAttemptClosed 表示企业微信驳回、撤销或删除退款审批尝试。 + AuditActionRefundAttemptClosed = "refund.attempt_close" + // AuditActionRefundAnomalyFlagged 表示企业微信通过后撤销,只写正交异常标记并转人工处理。 + AuditActionRefundAnomalyFlagged = "refund.anomaly_flag" + // AuditActionRefundChannelCalled 表示按退款方式发起渠道原路退款调用。 + AuditActionRefundChannelCalled = "refund.channel_call" + // AuditActionRefundChannelRecovered 表示恢复任务按渠道退款请求号回填原路退款结果。 + AuditActionRefundChannelRecovered = "refund.channel_recover" // AuditActionApprovalRequested 表示创建通用审批实例并请求渠道提交。 AuditActionApprovalRequested = "approval.request" // AuditActionApprovalSubmissionSynced 表示同步审批渠道提交结果。 @@ -648,6 +660,10 @@ const ( AuditResourceOrder = "order" // AuditResourceRefund 表示退款资源。 AuditResourceRefund = "refund" + // AuditResourceRefundAttempt 表示退款审批尝试记录资源,主键同时充当审批业务标识。 + AuditResourceRefundAttempt = "refund_attempt" + // AuditResourceRefundChannelRefund 表示退款单上的渠道原路退款事实资源。 + AuditResourceRefundChannelRefund = "refund_channel_refund" // AuditResourceAccount 表示后台账号资源。 AuditResourceAccount = "account" // AuditResourceRole 表示后台角色资源。 @@ -936,6 +952,8 @@ const ( AuditResourceRoleRefundPackageUsage = "refund_package_usage" // AuditResourceRoleRefundNotification 表示退款完成通知的可靠 Outbox 事实。 AuditResourceRoleRefundNotification = "refund_notification" + // AuditResourceRoleRefundChannelRefund 表示退款单上的渠道原路退款事实。 + AuditResourceRoleRefundChannelRefund = "refund_channel_refund" // AuditResourceRoleCommissionRecord 表示佣金计算或入账涉及的佣金记录。 AuditResourceRoleCommissionRecord = "commission_record" // AuditResourceRoleCommissionOrder 表示佣金关联订单。 @@ -1065,6 +1083,8 @@ const ( AuditActorIDRefundAssetPostProcessing = "refund_asset_post_processing" // AuditActorIDRefundCommissionPostProcessing 表示退款佣金自动回扣任务。 AuditActorIDRefundCommissionPostProcessing = "refund_commission_post_processing" + // AuditActorIDRefundChannel 表示退款渠道原路退款的调用与恢复任务。 + AuditActorIDRefundChannel = "refund_channel" // AuditActorIDCommissionCalculationWorker 表示订单佣金计算任务。 AuditActorIDCommissionCalculationWorker = "commission_calculation_worker" // AuditActorIDRetentionWorker 表示日志留存清理任务。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index b8ba3e0..070a4bf 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -91,6 +91,7 @@ const ( TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单 + TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询 ) // 用户状态常量 @@ -306,7 +307,7 @@ func QueueForTaskType(taskType string) string { return QueueCardObservationSeries case TaskTypeWeComApprovalSync, TaskTypeWeComApprovalRecovery: return QueueWeComApproval - case TaskTypeAgentRechargeRecovery: + case TaskTypeAgentRechargeRecovery, TaskTypeRefundChannelRecovery: return QueueDefault default: return QueueDefault diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 63f1889..f5f2a01 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -57,12 +57,12 @@ const ( // Gateway 卡状态 const ( - GatewayCardStatusReady = "准备" // 网关卡状态:准备 - GatewayCardStatusNormal = "正常" // 网关卡状态:正常 - GatewayCardStatusStopped = "停机" // 网关卡状态:停机 + GatewayCardStatusReady = "准备" // 网关卡状态:准备 + GatewayCardStatusNormal = "正常" // 网关卡状态:正常 + GatewayCardStatusStopped = "停机" // 网关卡状态:停机 GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活 GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机 - GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值 + GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值 GatewayCardExtendRiskStop = "风险停机" // 网关扩展状态:运营商风险停机(独立卡不允许复机) GatewayCardExtendCancelled = "已销户" // 网关扩展状态:已销户(独立卡不允许复机) ) @@ -409,7 +409,7 @@ func GetOrderCommissionResultName(result int) string { } // GetRefundStatusName 获取退款申请状态名称 -// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回 +// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回, 5=原路退款处理中, 6=原路退款失败 func GetRefundStatusName(status int) string { switch status { case 1: @@ -420,6 +420,10 @@ func GetRefundStatusName(status int) string { return "已拒绝" case 4: return "已退回" + case 5: + return "原路退款处理中" + case 6: + return "原路退款失败" default: return "未知" } diff --git a/pkg/constants/refund.go b/pkg/constants/refund.go new file mode 100644 index 0000000..650fd05 --- /dev/null +++ b/pkg/constants/refund.go @@ -0,0 +1,110 @@ +package constants + +const ( + // RefundMethodOriginalRoute 表示按原支付渠道原路退款。 + RefundMethodOriginalRoute = "original_route" + // RefundMethodCustomerAccount 表示退至客户提供的收款信息。 + RefundMethodCustomerAccount = "customer_account" + // RefundMethodAssetWallet 表示退回原资产钱包。 + RefundMethodAssetWallet = "asset_wallet" + // RefundMethodAgentWallet 表示退回原代理主钱包。 + RefundMethodAgentWallet = "agent_wallet" +) + +// RefundMethodName 返回退款方式中文名称。 +func RefundMethodName(method string) string { + switch method { + case RefundMethodOriginalRoute: + return "原路退款" + case RefundMethodCustomerAccount: + return "客户收款信息退款" + case RefundMethodAssetWallet: + return "退回资产钱包" + case RefundMethodAgentWallet: + return "退回代理主钱包" + default: + return "未知方式" + } +} + +const ( + // RefundChannelStatusNone 表示未发起渠道退款或不适用该方式。 + RefundChannelStatusNone = 0 + // RefundChannelStatusProcessing 表示渠道退款已提交但结果未确认。 + RefundChannelStatusProcessing = 1 + // RefundChannelStatusSucceeded 表示渠道明确退款成功。 + RefundChannelStatusSucceeded = 2 + // RefundChannelStatusFailed 表示渠道明确退款失败。 + RefundChannelStatusFailed = 3 +) + +// RefundChannelStatusName 返回渠道退款状态中文名称。 +func RefundChannelStatusName(status int) string { + switch status { + case RefundChannelStatusProcessing: + return "处理中" + case RefundChannelStatusSucceeded: + return "已成功" + case RefundChannelStatusFailed: + return "已失败" + default: + return "未发起" + } +} + +// 退款结构化失败分类稳定编码。 +// 分类既用于详情展示与审计,也用于后续佣金回溯判定「明确失败可回溯 / 在途不可回溯」。 +const ( + // RefundFailureChannelRejected 表示渠道明确拒绝退款。 + RefundFailureChannelRejected = "channel_rejected" + // RefundFailureCredentialInvalid 表示该商户退款必需凭证缺失或失效,渠道侧不可执行。 + RefundFailureCredentialInvalid = "credential_invalid" + // RefundFailureInsufficientBalance 表示渠道账户余额不足。 + RefundFailureInsufficientBalance = "insufficient_balance" + // RefundFailureTimeoutUnknown 表示调用超时或渠道结果未确认,属可恢复状态。 + RefundFailureTimeoutUnknown = "timeout_unknown" + // RefundFailureApprovalRejected 表示企业微信驳回、撤销或删除。 + RefundFailureApprovalRejected = "approval_rejected" + // RefundFailureRevokedAfterApproved 表示企业微信通过后撤销,属需人工处理的异常。 + RefundFailureRevokedAfterApproved = "revoked_after_approved" + // RefundFailurePaymentFactInvalid 表示本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。 + RefundFailurePaymentFactInvalid = "payment_fact_invalid" +) + +// RefundFailureReasonName 返回失败分类中文名称。 +func RefundFailureReasonName(reason string) string { + switch reason { + case RefundFailureChannelRejected: + return "渠道明确拒绝" + case RefundFailureCredentialInvalid: + return "渠道凭证失效" + case RefundFailureInsufficientBalance: + return "渠道余额不足" + case RefundFailureTimeoutUnknown: + return "超时或结果未知" + case RefundFailureApprovalRejected: + return "企业微信驳回或关闭" + case RefundFailureRevokedAfterApproved: + return "企业微信通过后撤销" + case RefundFailurePaymentFactInvalid: + return "本地原支付事实不可用" + default: + return "无失败" + } +} + +// RefundFailureReasonIsDefinitive 判断失败分类是否属于明确失败。 +// 明确失败表示退款已终结且可进入后续回溯判定;超时未知与企业微信通过后撤销均非明确失败, +// 前者保持可恢复、后者转人工处理,都不得据此回溯佣金。 +func RefundFailureReasonIsDefinitive(reason string) bool { + switch reason { + case RefundFailureChannelRejected, + RefundFailureCredentialInvalid, + RefundFailureInsufficientBalance, + RefundFailureApprovalRejected, + RefundFailurePaymentFactInvalid: + return true + default: + return false + } +} diff --git a/pkg/fuiou/refund.go b/pkg/fuiou/refund.go new file mode 100644 index 0000000..7a8c845 --- /dev/null +++ b/pkg/fuiou/refund.go @@ -0,0 +1,206 @@ +package fuiou + +import ( + "crypto/rand" + "encoding/xml" + "fmt" + "strings" + "time" + + "go.uber.org/zap" +) + +// 退款接口结果常量 +const ( + ResultCodeSuccess = "000000" // 富友接口成功结果码 + TransStatSuccess = "SUCCESS" // 退款交易状态:退款成功 + TransStatPayError = "PAYERROR" // 退款交易状态:退款失败 +) + +// 渠道退款请求号(refund_order_no)生成规则参数 +const ( + // refundOrderNoAlphabet 随机段字符集:大写字母与数字 + refundOrderNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + // refundOrderNoRandomLen 随机段长度,取富友规则上限 18 位 + refundOrderNoRandomLen = 18 + // refundOrderNoLength 渠道退款请求号总长:机构码 4 + 日期 8 + 随机段 18 + refundOrderNoLength = 30 +) + +// shanghaiLocation 上海时区(东八区),用于按富友规则生成日期段 +var shanghaiLocation = time.FixedZone("CST", 8*3600) + +// CommonRefundRequest 退款申请请求(POST /commonRefund)。 +// reserved 开头字段与 sign 不参与签名,但仍须随 XML 发出(不得加 omitempty)。 +type CommonRefundRequest struct { + XMLName xml.Name `xml:"xml"` + Version string `xml:"version"` // 版本号: 1.0 + InsCd string `xml:"ins_cd"` // 机构号 + MchntCd string `xml:"mchnt_cd"` // 商户号 + TermId string `xml:"term_id"` // 终端号 + MchntOrderNo string `xml:"mchnt_order_no"` // 原支付商户订单号 + RandomStr string `xml:"random_str"` // 随机字符串 + Sign string `xml:"sign"` // 签名 + OrderType string `xml:"order_type"` // 原交易订单类型,须与原支付一致 + RefundOrderNo string `xml:"refund_order_no"` // 商户退款订单号(全局永久唯一) + TotalAmt string `xml:"total_amt"` // 原订单总金额(分) + RefundAmt string `xml:"refund_amt"` // 本次退款金额(分) + OperatorId string `xml:"operator_id"` // 操作员(可选) + ReservedFyTermId string `xml:"reserved_fy_term_id"` // 富友终端号(reserved,不参与签名) + ReservedOrigiDt string `xml:"reserved_origi_dt"` // 原交易日期(reserved,不参与签名) + ReservedAddnInf string `xml:"reserved_addn_inf"` // 附加数据(reserved,不参与签名) + ReservedRefundDesc string `xml:"reserved_refund_desc"` // 退款备注(reserved,不参与签名) +} + +// CommonRefundResponse 退款申请响应 +type CommonRefundResponse struct { + ResultCode string `xml:"result_code"` // 结果码: 000000=成功 + ResultMsg string `xml:"result_msg"` // 结果消息 + InsCd string `xml:"ins_cd"` // 机构号 + MchntCd string `xml:"mchnt_cd"` // 商户号 + RandomStr string `xml:"random_str"` // 随机字符串 + Sign string `xml:"sign"` // 签名 + RefundId string `xml:"refund_id"` // 富友退款流水号 + TransactionId string `xml:"transaction_id"` // 富友交易流水号 + ReservedRefundAmt string `xml:"reserved_refund_amt"` // 退款金额(分,reserved) + ReservedFySettleDt string `xml:"reserved_fy_settle_dt"` // 富友清算日期(reserved) +} + +// RefundQueryRequest 退款查询请求(POST /refundQuery)。 +// 仅支持查询 3 日内的退款交易。 +type RefundQueryRequest struct { + XMLName xml.Name `xml:"xml"` + Version string `xml:"version"` // 版本号: 1.0 + InsCd string `xml:"ins_cd"` // 机构号 + MchntCd string `xml:"mchnt_cd"` // 商户号 + TermId string `xml:"term_id"` // 终端号 + RandomStr string `xml:"random_str"` // 随机字符串 + Sign string `xml:"sign"` // 签名 + RefundOrderNo string `xml:"refund_order_no"` // 商户退款订单号 +} + +// RefundQueryResponse 退款查询响应 +type RefundQueryResponse struct { + ResultCode string `xml:"result_code"` // 结果码: 000000=成功 + ResultMsg string `xml:"result_msg"` // 结果消息 + InsCd string `xml:"ins_cd"` // 机构号 + MchntCd string `xml:"mchnt_cd"` // 商户号 + RandomStr string `xml:"random_str"` // 随机字符串 + Sign string `xml:"sign"` // 签名 + TransStat string `xml:"trans_stat"` // 退款交易状态: SUCCESS=成功 PAYERROR=失败 + RefundId string `xml:"refund_id"` // 富友退款流水号 + TransactionId string `xml:"transaction_id"` // 富友交易流水号 + ReservedRefundAmt string `xml:"reserved_refund_amt"` // 退款金额(分,reserved) + ReservedFySettleDt string `xml:"reserved_fy_settle_dt"` // 富友清算日期(reserved) +} + +// CommonRefund 申请渠道原路退款(POST /commonRefund)。 +// 调用方只需填写业务字段(mchnt_order_no、order_type、refund_order_no、total_amt、refund_amt 及可选字段), +// 公共字段由客户端填充并签名。reserved 开头字段随 XML 发出但不参与签名。 +// 本方法只做渠道原始调用与结果映射,不含幂等键生成、状态机、数据库写入与审计。 +func (c *Client) CommonRefund(req *CommonRefundRequest) (*CommonRefundResponse, error) { + if req == nil { + return nil, fmt.Errorf("富友退款申请请求为空") + } + + req.Version = "1.0" + req.InsCd = c.InsCd + req.MchntCd = c.MchntCd + req.TermId = c.TermId + req.RandomStr = generateRandomStr() + + sign, err := c.Sign(req) + if err != nil { + return nil, fmt.Errorf("签名失败: %w", err) + } + req.Sign = sign + + var resp CommonRefundResponse + if err := c.DoRequest("/commonRefund", req, &resp); err != nil { + return nil, fmt.Errorf("请求富友失败: %w", err) + } + + if resp.ResultCode != ResultCodeSuccess { + c.logger.Error("富友退款申请失败", + zap.String("refund_order_no", req.RefundOrderNo), + zap.String("mchnt_order_no", req.MchntOrderNo), + zap.String("result_code", resp.ResultCode), + zap.String("result_msg", resp.ResultMsg), + ) + return nil, fmt.Errorf("富友退款申请失败: %s", resp.ResultMsg) + } + + c.logger.Info("富友退款申请成功", + zap.String("refund_order_no", req.RefundOrderNo), + zap.String("mchnt_order_no", req.MchntOrderNo), + zap.String("refund_id", resp.RefundId), + ) + + return &resp, nil +} + +// RefundQuery 查询退款交易状态(POST /refundQuery)。 +// refundOrderNo: 发起退款时使用的商户退款订单号。 +// 富友仅支持查询 3 日内的退款交易,超期查询由调用方处理。 +func (c *Client) RefundQuery(refundOrderNo string) (*RefundQueryResponse, error) { + req := &RefundQueryRequest{ + Version: "1.0", + InsCd: c.InsCd, + MchntCd: c.MchntCd, + TermId: c.TermId, + RandomStr: generateRandomStr(), + RefundOrderNo: refundOrderNo, + } + + sign, err := c.Sign(req) + if err != nil { + return nil, fmt.Errorf("签名失败: %w", err) + } + req.Sign = sign + + var resp RefundQueryResponse + if err := c.DoRequest("/refundQuery", req, &resp); err != nil { + return nil, fmt.Errorf("请求富友失败: %w", err) + } + + if resp.ResultCode != ResultCodeSuccess { + c.logger.Error("富友退款查询失败", + zap.String("refund_order_no", refundOrderNo), + zap.String("result_code", resp.ResultCode), + zap.String("result_msg", resp.ResultMsg), + ) + return nil, fmt.Errorf("富友退款查询失败: %s", resp.ResultMsg) + } + + return &resp, nil +} + +// BuildRefundOrderNo 按富友流水号规则生成渠道退款请求号。 +// 格式:机构码(4) + 日期(yyyyMMdd,上海时区) + 随机段(18 位大写字母与数字),总长 30。 +// insCd 不足 4 位时左侧补 0,超过 4 位时取前 4 位;日期取 now 对应的上海时区日期。 +func BuildRefundOrderNo(insCd string, now time.Time) string { + var b strings.Builder + b.Grow(refundOrderNoLength) + b.WriteString(normalizeInsCd(insCd)) + b.WriteString(now.In(shanghaiLocation).Format("20060102")) + + buf := make([]byte, refundOrderNoRandomLen) + if _, err := rand.Read(buf); err != nil { + // 随机源不可用时退回既有 32 位十六进制随机串,保证结果仍满足格式与长度约束 + b.WriteString(strings.ToUpper(generateRandomStr())[:refundOrderNoRandomLen]) + return b.String() + } + for _, v := range buf { + b.WriteByte(refundOrderNoAlphabet[int(v)%len(refundOrderNoAlphabet)]) + } + + return b.String() +} + +// normalizeInsCd 将机构码规整为 4 位:不足左侧补 0,超过取前 4 位。 +func normalizeInsCd(insCd string) string { + if len(insCd) >= 4 { + return insCd[:4] + } + return strings.Repeat("0", 4-len(insCd)) + insCd +} diff --git a/pkg/wechat/config.go b/pkg/wechat/config.go index 01d3415..7877ad4 100644 --- a/pkg/wechat/config.go +++ b/pkg/wechat/config.go @@ -122,7 +122,8 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac } // NewPaymentV2ServiceFromConfig 从数据库配置创建微信支付 v2 服务实例 -// v2 仅需 APIv2Key,不要求证书序列号和 v3 密钥 +// v2 仅需 APIv2Key,不要求证书序列号和 v3 密钥。 +// 若配置含 API 客户端证书,则一并在实例上注入,使该商户具备 v2 退款(双向证书)能力。 func NewPaymentV2ServiceFromConfig(wechatConfig *model.WechatConfig, appID string, logger *zap.Logger) (*PaymentV2Service, error) { if wechatConfig == nil { return nil, fmt.Errorf("微信配置不能为空") @@ -134,7 +135,12 @@ func NewPaymentV2ServiceFromConfig(wechatConfig *model.WechatConfig, appID strin return nil, fmt.Errorf("微信支付 v2 配置不完整:缺少 wx_mch_id 或 wx_api_v2_key") } - return NewPaymentV2Service(appID, wechatConfig.WxMchID, wechatConfig.WxAPIV2Key, wechatConfig.WxNotifyURL, logger), nil + service := NewPaymentV2Service(appID, wechatConfig.WxMchID, wechatConfig.WxAPIV2Key, wechatConfig.WxNotifyURL, logger) + // 证书不可用时不影响 v2 支付与查单:退款能力随之为不可用,由凭证完整性判定体现。 + if err := service.SetClientCertificate(wechatConfig.WxClientCertContent, wechatConfig.WxClientKeyContent); err != nil { + logger.Warn("微信支付 v2 客户端证书不可用,退款能力将被判定为不可用", zap.Error(err)) + } + return service, nil } func writeWechatPemTempFile(pattern, content string) (string, error) { diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go index 7329da3..12515cb 100644 --- a/pkg/wechat/payment_v2.go +++ b/pkg/wechat/payment_v2.go @@ -33,6 +33,11 @@ type PaymentV2Service struct { apiKey string notifyURL string logger *zap.Logger + // 退款接口需要双向证书,因此单独持有带客户端证书的 HTTP 客户端; + // 未配置证书时 refundClient 为 nil,退款能力按凭证不完整判定为不可用。 + refundClient *http.Client + clientCertPEM string + clientKeyPEM string } // NewPaymentV2Service 创建微信支付 v2 服务 diff --git a/pkg/wechat/payment_v2_refund.go b/pkg/wechat/payment_v2_refund.go new file mode 100644 index 0000000..d032de1 --- /dev/null +++ b/pkg/wechat/payment_v2_refund.go @@ -0,0 +1,310 @@ +package wechat + +import ( + "context" + "crypto/tls" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// 微信支付 v2 退款相关端点。退款申请为双向证书接口,退款查询不需要证书。 +const ( + wechatPayV2RefundURL = "https://api.mch.weixin.qq.com/secapi/pay/refund" + wechatPayV2RefundQueryURL = "https://api.mch.weixin.qq.com/pay/refundquery" +) + +// 微信支付 v2 退款状态(refundquery 的 refund_status_$n 取值)。 +const ( + v2RefundStatusSuccess = "SUCCESS" // 退款成功 + v2RefundStatusRefundClose = "REFUNDCLOSE" // 退款关闭 + v2RefundStatusProcessing = "PROCESSING" // 退款处理中 + v2RefundStatusChange = "CHANGE" // 退款异常,需人工处理 +) + +// SetClientCertificate 注入 API 客户端证书,用于微信支付 v2 的双向证书退款接口。 +// certPEM 为 apiclient_cert.pem 内容,keyPEM 为 apiclient_key.pem 内容。 +// 两者任一为空时清除证书,退款能力随之为不可用;支付与查单接口不依赖该证书。 +func (s *PaymentV2Service) SetClientCertificate(certPEM, keyPEM string) error { + if strings.TrimSpace(certPEM) == "" || strings.TrimSpace(keyPEM) == "" { + s.refundClient = nil + s.clientCertPEM = "" + s.clientKeyPEM = "" + return nil + } + pair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err != nil { + return fmt.Errorf("微信支付 v2 客户端证书不可用: %w", err) + } + s.clientCertPEM = certPEM + s.clientKeyPEM = keyPEM + s.refundClient = &http.Client{ + Transport: &http.Transport{ + // 退款接口要求商户出示 API 客户端证书,即双向 TLS。 + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{pair}, + MinVersion: tls.VersionTLS12, + }, + }, + } + return nil +} + +// HasClientCertificate 报告该实例是否具备可执行双向证书退款请求的客户端证书。 +func (s *PaymentV2Service) HasClientCertificate() bool { + return s != nil && s.refundClient != nil +} + +// V2RefundRequest 微信支付 v2 原路退款请求。 +// 金额单位均为分;结构不包含任何通知地址字段,退款结果只由同步响应与退款查询确认。 +type V2RefundRequest struct { + OutTradeNo string // 原支付单商户订单号,与 TransactionID 二选一 + TransactionID string + OutRefundNo string // 商户退款单号 + TotalFee int64 // 原订单总金额(分) + RefundFee int64 // 本次退款金额(分) + RefundDesc string // 退款原因,可选 +} + +// V2RefundResult 微信支付 v2 退款结果。 +// +// 退款申请接口的返回只代表渠道受理情况,渠道明确要求通过退款查询获取最终结果, +// 因此 RefundOrder 的受理成功不等于退款成功(Success 为 false),须由退款查询确认。 +type V2RefundResult struct { + // Success 仅当渠道明确退款成功时为 true。 + Success bool + // Accepted 表示渠道已受理该退款申请(申请接口 result_code=SUCCESS)。 + Accepted bool + RefundID string // 微信退款单号 + OutRefundNo string + OutTradeNo string + TransactionID string + // Status 渠道退款状态原文:SUCCESS/REFUNDCLOSE/PROCESSING/CHANGE;申请接口不返回该字段。 + Status string + RefundFee int64 + // ErrCode 渠道业务错误码,仅渠道明确拒绝时非空。 + ErrCode string + Message string +} + +// v2RefundRequest 退款申请请求(v2 XML 格式)。 +type v2RefundRequest struct { + XMLName xml.Name `xml:"xml"` + AppID string `xml:"appid"` + MchID string `xml:"mch_id"` + NonceStr string `xml:"nonce_str"` + Sign string `xml:"sign"` + OutTradeNo string `xml:"out_trade_no,omitempty"` + TransactionID string `xml:"transaction_id,omitempty"` + OutRefundNo string `xml:"out_refund_no"` + TotalFee int64 `xml:"total_fee"` + RefundFee int64 `xml:"refund_fee"` + RefundDesc string `xml:"refund_desc,omitempty"` +} + +// v2RefundResponse 退款申请响应(v2 XML 格式)。 +type v2RefundResponse struct { + XMLName xml.Name `xml:"xml"` + ReturnCode string `xml:"return_code"` + ReturnMsg string `xml:"return_msg"` + ResultCode string `xml:"result_code"` + ErrCode string `xml:"err_code"` + ErrCodeDes string `xml:"err_code_des"` + TransactionID string `xml:"transaction_id"` + OutTradeNo string `xml:"out_trade_no"` + OutRefundNo string `xml:"out_refund_no"` + RefundID string `xml:"refund_id"` + RefundFee int64 `xml:"refund_fee"` + TotalFee int64 `xml:"total_fee"` + CashRefundFee int64 `xml:"cash_refund_fee"` +} + +// v2RefundQueryRequest 退款查询请求(v2 XML 格式)。 +type v2RefundQueryRequest struct { + XMLName xml.Name `xml:"xml"` + AppID string `xml:"appid"` + MchID string `xml:"mch_id"` + NonceStr string `xml:"nonce_str"` + Sign string `xml:"sign"` + OutRefundNo string `xml:"out_refund_no"` +} + +// v2RefundQueryResponse 退款查询响应(v2 XML 格式)。 +// 微信按退款笔数下标返回字段,本系统按商户退款单号查询,因此取第 0 笔。 +type v2RefundQueryResponse struct { + XMLName xml.Name `xml:"xml"` + ReturnCode string `xml:"return_code"` + ReturnMsg string `xml:"return_msg"` + ResultCode string `xml:"result_code"` + ErrCode string `xml:"err_code"` + ErrCodeDes string `xml:"err_code_des"` + OutTradeNo string `xml:"out_trade_no"` + TransactionID string `xml:"transaction_id"` + RefundCount int `xml:"refund_count"` + OutRefundNo0 string `xml:"out_refund_no_0"` + RefundID0 string `xml:"refund_id_0"` + RefundStatus0 string `xml:"refund_status_0"` + RefundFee0 int64 `xml:"refund_fee_0"` + SettlementRefundFee0 int64 `xml:"settlement_refund_fee_0"` +} + +// RefundOrder 申请微信支付 v2 原路退款(/secapi/pay/refund,双向证书)。 +// +// 渠道契约明确规定:申请接口的返回仅代表业务受理情况,退款是否成功必须通过退款查询获取, +// 因此本方法在渠道受理成功时返回 Accepted=true 且 Success=false;渠道明确拒绝时返回 +// ErrCode 与 Message 且不返回 error,只有传输或解析失败才作为 error 返回。 +// 本方法不传递任何退款结果通知地址。 +func (s *PaymentV2Service) RefundOrderV2(ctx context.Context, req V2RefundRequest) (*V2RefundResult, error) { + if req.OutTradeNo == "" && req.TransactionID == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户订单号与微信订单号至少填写一个") + } + if req.OutRefundNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空") + } + if req.TotalFee <= 0 || req.RefundFee <= 0 || req.RefundFee > req.TotalFee { + return nil, errors.New(errors.CodeInvalidParam, "退款金额必须为正且不超过原订单金额") + } + if !s.HasClientCertificate() { + return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 退款缺少 API 客户端证书") + } + + nonceStr := v2GenerateNonceStr() + params := map[string]string{ + "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, + "out_trade_no": req.OutTradeNo, "transaction_id": req.TransactionID, + "out_refund_no": req.OutRefundNo, + "total_fee": fmt.Sprintf("%d", req.TotalFee), + "refund_fee": fmt.Sprintf("%d", req.RefundFee), + "refund_desc": req.RefundDesc, + } + params["sign"] = v2SignMD5(params, s.apiKey) + body := &v2RefundRequest{ + AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"], + OutTradeNo: params["out_trade_no"], TransactionID: params["transaction_id"], + OutRefundNo: params["out_refund_no"], + TotalFee: req.TotalFee, RefundFee: req.RefundFee, RefundDesc: req.RefundDesc, + } + + respBytes, err := s.postV2XMLWithClient(ctx, s.refundClient, wechatPayV2RefundURL, body) + if err != nil { + s.logger.Error("调用微信 v2 退款接口失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") + } + var resp v2RefundResponse + if err = xml.Unmarshal(respBytes, &resp); err != nil { + s.logger.Error("解析微信 v2 退款响应失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") + } + if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { + s.logger.Error("微信 v2 退款响应验签失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败") + } + + result := &V2RefundResult{ + RefundID: resp.RefundID, OutRefundNo: resp.OutRefundNo, OutTradeNo: resp.OutTradeNo, + TransactionID: resp.TransactionID, RefundFee: resp.RefundFee, + } + if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" { + // 渠道明确拒绝受理:返回渠道错误码供调用方归类,不视为传输失败。 + result.ErrCode = resp.ErrCode + result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg) + s.logger.Warn("微信 v2 退款未被受理", zap.String("out_refund_no", req.OutRefundNo), + zap.String("err_code", resp.ErrCode), zap.String("return_msg", resp.ReturnMsg)) + return result, nil + } + // 受理成功:渠道要求以退款查询确认最终结果。 + result.Accepted = true + result.Message = "渠道已受理退款申请,等待退款查询确认结果" + s.logger.Info("微信 v2 退款已受理", zap.String("out_refund_no", req.OutRefundNo), zap.String("refund_id", resp.RefundID)) + return result, nil +} + +// QueryRefund 查询微信支付 v2 退款结果(/pay/refundquery,不需要证书)。 +// 仅 refund_status=SUCCESS 视为渠道明确退款成功;REFUNDCLOSE 与 CHANGE 视为明确失败; +// PROCESSING 或未返回状态保持结果未知。 +func (s *PaymentV2Service) QueryRefund(ctx context.Context, outRefundNo string) (*V2RefundResult, error) { + if outRefundNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空") + } + + nonceStr := v2GenerateNonceStr() + params := map[string]string{ + "appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, "out_refund_no": outRefundNo, + } + params["sign"] = v2SignMD5(params, s.apiKey) + body := &v2RefundQueryRequest{ + AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], + Sign: params["sign"], OutRefundNo: params["out_refund_no"], + } + + respBytes, err := s.postV2XML(ctx, wechatPayV2RefundQueryURL, body) + if err != nil { + s.logger.Error("调用微信 v2 退款查询失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") + } + var resp v2RefundQueryResponse + if err = xml.Unmarshal(respBytes, &resp); err != nil { + s.logger.Error("解析微信 v2 退款查询响应失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") + } + if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil { + s.logger.Error("微信 v2 退款查询响应验签失败", zap.String("out_refund_no", outRefundNo), zap.Error(err)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败") + } + + result := &V2RefundResult{ + OutRefundNo: resp.OutRefundNo0, RefundID: resp.RefundID0, OutTradeNo: resp.OutTradeNo, + TransactionID: resp.TransactionID, RefundFee: resp.RefundFee0, Status: resp.RefundStatus0, + } + if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" { + result.ErrCode = resp.ErrCode + result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg) + return result, nil + } + switch resp.RefundStatus0 { + case v2RefundStatusSuccess: + result.Success = true + result.Message = "退款成功" + case v2RefundStatusRefundClose: + result.Message = "退款关闭" + case v2RefundStatusChange: + result.Message = "退款异常,需人工核对退款账户" + case v2RefundStatusProcessing: + result.Message = "退款处理中" + default: + result.Message = "退款状态未知" + } + return result, nil +} + +// postV2XMLWithClient 使用指定 HTTP 客户端发送 v2 XML 请求。 +// 退款接口必须使用持有商户 API 客户端证书的客户端,支付与查单沿用默认客户端。 +func (s *PaymentV2Service) postV2XMLWithClient(ctx context.Context, client *http.Client, endpoint string, body any) ([]byte, error) { + if client == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 请求客户端不可用") + } + xmlBytes, err := xml.Marshal(body) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(xmlBytes))) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/xml") + httpResp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("微信支付接口返回 HTTP 状态码 %d", httpResp.StatusCode) + } + return io.ReadAll(io.LimitReader(httpResp.Body, 1<<20)) +} diff --git a/pkg/wechat/refund.go b/pkg/wechat/refund.go new file mode 100644 index 0000000..cd0316f --- /dev/null +++ b/pkg/wechat/refund.go @@ -0,0 +1,215 @@ +package wechat + +import ( + "context" + + refundRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/request" + refundResponse "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/response" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "go.uber.org/zap" +) + +// 微信支付 v3 退款单状态(渠道 status 字段原文) +const ( + // refundStatusSuccess 退款成功 + refundStatusSuccess = "SUCCESS" + // refundStatusClosed 退款关闭 + refundStatusClosed = "CLOSED" + // refundStatusProcessing 退款处理中 + refundStatusProcessing = "PROCESSING" + // refundStatusAbnormal 退款异常 + refundStatusAbnormal = "ABNORMAL" +) + +// RefundOrderRequest 微信支付 v3 原路退款请求 +// +// 金额单位均为分,且均为整型直传渠道;本结构不包含任何通知地址字段, +// 退款进度由调用方通过 QueryRefund 主动查询获得。 +type RefundOrderRequest struct { + // OutTradeNo 商户订单号(原支付单号) + OutTradeNo string `json:"out_trade_no"` + // OutRefundNo 商户退款单号 + OutRefundNo string `json:"out_refund_no"` + // Amount 原订单金额,单位分 + Amount int64 `json:"amount"` + // Refund 本次退款金额,单位分 + Refund int64 `json:"refund"` + // Reason 退款原因,可选 + Reason string `json:"reason"` + // Currency 退款币种,可选,为空时使用默认值 CNY + Currency string `json:"currency"` +} + +// RefundOrderResult 微信支付 v3 退款结果 +type RefundOrderResult struct { + // Success 是否退款成功,仅当渠道状态为 SUCCESS 时为 true + Success bool `json:"success"` + // RefundID 微信支付退款单号 + RefundID string `json:"refund_id"` + // OutRefundNo 商户退款单号 + OutRefundNo string `json:"out_refund_no"` + // OutTradeNo 商户订单号 + OutTradeNo string `json:"out_trade_no"` + // TransactionID 微信支付订单号 + TransactionID string `json:"transaction_id"` + // Status 渠道退款状态原文:SUCCESS/CLOSED/PROCESSING/ABNORMAL + Status string `json:"status"` + // RefundFee 退款金额,单位分 + RefundFee int64 `json:"refund_fee"` + // ChannelCode 渠道业务错误码,仅在渠道返回错误响应体时非空 + ChannelCode string `json:"channel_code"` + // Message 状态说明 + Message string `json:"message"` +} + +// RefundOrder 发起微信支付 v3 原路退款 +// +// 不向渠道传递任何通知地址(notify_url 留空,接口调用时该字段不会出现在请求体中), +// 退款结果以本次返回的 Status 为准,异步进度由调用方通过 QueryRefund 查询。 +func (s *PaymentService) RefundOrder(ctx context.Context, req RefundOrderRequest) (*RefundOrderResult, error) { + if req.OutTradeNo == "" || req.OutRefundNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户订单号和商户退款单号不能为空") + } + if req.Amount <= 0 || req.Refund <= 0 { + return nil, errors.New(errors.CodeInvalidParam, "原订单金额和退款金额必须大于 0") + } + if req.Refund > req.Amount { + return nil, errors.New(errors.CodeInvalidParam, "退款金额不能大于原订单金额") + } + + currency := req.Currency + if currency == "" { + currency = "CNY" + } + + resp, err := s.app.Refund.Refund(ctx, &refundRequest.RequestRefund{ + OutTradeNo: req.OutTradeNo, + OutRefundNo: req.OutRefundNo, + Reason: req.Reason, + Amount: &refundRequest.RefundAmount{ + Refund: int(req.Refund), + Total: int(req.Amount), + Currency: currency, + }, + }) + if err != nil { + s.logger.Error("发起微信退款失败", + zap.String("out_trade_no", req.OutTradeNo), + zap.String("out_refund_no", req.OutRefundNo), + zap.Int64("refund", req.Refund), + zap.Error(err), + ) + return nil, errors.Wrap(errors.CodeWechatPayFailed, err) + } + if resp == nil { + s.logger.Error("发起微信退款失败:空响应", + zap.String("out_trade_no", req.OutTradeNo), + zap.String("out_refund_no", req.OutRefundNo), + ) + return nil, errors.New(errors.CodeWechatPayFailed, "微信退款失败:渠道返回空响应") + } + + result := mapRefundResult(resp) + if result.Success { + s.logger.Info("发起微信退款成功", + zap.String("out_trade_no", req.OutTradeNo), + zap.String("out_refund_no", req.OutRefundNo), + zap.String("refund_id", result.RefundID), + zap.String("status", result.Status), + ) + } else { + s.logger.Warn("发起微信退款未成功", + zap.String("out_trade_no", req.OutTradeNo), + zap.String("out_refund_no", req.OutRefundNo), + zap.String("status", result.Status), + zap.String("channel_code", result.ChannelCode), + zap.String("message", result.Message), + ) + } + + return result, nil +} + +// QueryRefund 查询微信支付 v3 退款单 +// +// 状态与金额映射规则与 RefundOrder 一致。 +func (s *PaymentService) QueryRefund(ctx context.Context, outRefundNo string) (*RefundOrderResult, error) { + if outRefundNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空") + } + + resp, err := s.app.Refund.Query(ctx, outRefundNo) + if err != nil { + s.logger.Error("查询微信退款失败", + zap.String("out_refund_no", outRefundNo), + zap.Error(err), + ) + return nil, errors.Wrap(errors.CodeWechatPayFailed, err) + } + if resp == nil { + s.logger.Error("查询微信退款失败:空响应", zap.String("out_refund_no", outRefundNo)) + return nil, errors.New(errors.CodeWechatPayFailed, "查询微信退款失败:渠道返回空响应") + } + + result := mapRefundResult(resp) + s.logger.Debug("查询微信退款完成", + zap.String("out_refund_no", outRefundNo), + zap.String("refund_id", result.RefundID), + zap.String("status", result.Status), + zap.String("channel_code", result.ChannelCode), + ) + + return result, nil +} + +// mapRefundResult 将微信退款接口响应映射为统一结果 +// +// 渠道 status 为 SUCCESS 时 Success 为 true;CLOSED/ABNORMAL/PROCESSING 等明确状态 +// 返回结果且不报错,由调用方按 Status 与 Message 处理。 +// 渠道返回业务错误码(响应体含 code,通常为 4xx,退款单未被受理)同样返回结果, +// 由调用方按 ChannelCode 判定;只有传输或解析失败才在调用处作为 error 返回—— +// 这样调用方才能把「渠道明确表态」与「结果未知」区分开。 +func mapRefundResult(resp *refundResponse.ResponseRefund) *RefundOrderResult { + if resp.Code != "" { + return &RefundOrderResult{ + OutRefundNo: resp.OutRefundNO, + OutTradeNo: resp.OutTradeNO, + ChannelCode: resp.Code, + Message: "微信退款失败:" + resp.Code + " " + resp.Message, + } + } + + result := &RefundOrderResult{ + RefundID: resp.RefundID, + OutRefundNo: resp.OutRefundNO, + OutTradeNo: resp.OutTradeNO, + TransactionID: resp.TransactionID, + Status: resp.Status, + } + if resp.Amount != nil { + result.RefundFee = int64(resp.Amount.Refund) + } + + result.Success = resp.Status == refundStatusSuccess + result.Message = refundStatusMessage(resp.Status) + + return result +} + +// refundStatusMessage 返回微信退款状态的说明文案 +func refundStatusMessage(status string) string { + switch status { + case refundStatusSuccess: + return "退款成功" + case refundStatusClosed: + return "退款已关闭" + case refundStatusAbnormal: + return "退款异常" + case refundStatusProcessing: + return "退款处理中" + case "": + return "退款状态未知,请稍后查询" + default: + return "退款状态:" + status + } +}