diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 2eb89c5..3ed3e00 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -1095,7 +1095,8 @@ "requirements": [ "identity-access::数据范围拒绝", "order-refund-exchange::订单、退款与换货状态门禁", - "order-refund-exchange::代理退款查询按所属店铺隔离" + "order-refund-exchange::代理退款查询按所属店铺隔离", + "order-refund-exchange::退款终态事实与失败分类" ], "classification": "behavior" }, @@ -1106,7 +1107,8 @@ "requirements": [ "identity-access::数据范围拒绝", "order-refund-exchange::订单、退款与换货状态门禁", - "order-refund-exchange::代理退款查询按所属店铺隔离" + "order-refund-exchange::代理退款查询按所属店铺隔离", + "order-refund-exchange::退款终态事实与失败分类" ], "classification": "behavior" }, @@ -2322,7 +2324,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::退款实收金额与方式矩阵" ], "classification": "behavior" }, @@ -2355,7 +2358,8 @@ "capability": "order-refund-exchange", "requirements": [ "identity-access::数据范围拒绝", - "order-refund-exchange::订单、退款与换货状态门禁" + "order-refund-exchange::订单、退款与换货状态门禁", + "order-refund-exchange::企业微信唯一终审与审批尝试重提" ], "classification": "behavior" }, @@ -2795,7 +2799,9 @@ "capability": "external-integration", "requirements": [ "external-integration::企微回调与补偿", - "employee-collection-bill::核销申请审批、重提与幂等" + "employee-collection-bill::核销申请审批、重提与幂等", + "order-refund-exchange::企业微信唯一终审与审批尝试重提", + "order-refund-exchange::退款权益与订单状态时点" ], "classification": "behavior" }, @@ -3931,7 +3937,9 @@ "capability": "order-refund-exchange", "requirements": [ "order-refund-exchange::订单、退款与换货状态门禁", - "order-refund-exchange::代理退款查询按所属店铺隔离" + "order-refund-exchange::代理退款查询按所属店铺隔离", + "order-refund-exchange::原路退款渠道能力与执行", + "order-refund-exchange::退款终态事实与失败分类" ], "classification": "route_index_or_infrastructure" }, diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index f050cc2..9a48405 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3163,5 +3163,169 @@ ], "exit_status": 0 } + }, + { + "capability": "order-refund-exchange", + "requirement": "退款实收金额与方式矩阵", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/refunds" + ], + "handler_consumer_job": [ + "internal/routes/refund.go", + "internal/handler/admin/refund.go" + ], + "application_service_query": [ + "internal/service/refund/method.go" + ], + "domain_state_amount": [ + "internal/model/refund.go", + "internal/model/payment.go", + "pkg/constants/refund.go" + ], + "store_migration_config": [ + "migrations/000218_add_refund_attempt_and_channel_refund.up.sql" + ], + "verification": { + "command": "grep -n 'func (s \\*Service) decideRefundMethods' internal/service/refund/method.go", + "literal_output": [ + "internal/service/refund/method.go:40:func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) {" + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "企业微信唯一终审与审批尝试重提", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/refunds/{id}/resubmit", + "/api/callback/wecom/approval/{application_id}" + ], + "handler_consumer_job": [ + "internal/routes/refund.go", + "internal/handler/admin/refund.go", + "internal/application/refundapproval/creation.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/refundapproval/resolve.go", + "internal/service/refund/service.go" + ], + "domain_state_amount": [ + "pkg/constants/approval.go", + "internal/model/refund.go" + ], + "store_migration_config": [ + "migrations/000218_add_refund_attempt_and_channel_refund.up.sql" + ], + "verification": { + "command": "grep -n 'BusinessID: attempt.ID' internal/application/refundapproval/creation.go", + "literal_output": [ + "internal/application/refundapproval/creation.go:141:BusinessID: attempt.ID, SubmitterAccountID: current.Creator,", + "internal/application/refundapproval/creation.go:358:BusinessID: attempt.ID, SubmitterAccountID: current.Creator," + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "原路退款渠道能力与执行", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "constants.TaskTypeRefundChannelRecovery" + ], + "handler_consumer_job": [ + "internal/infrastructure/payment/refund_channel_recovery_task.go", + "internal/infrastructure/payment/refund_adapter.go", + "internal/application/refundchannel/event.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/refundchannel/service.go", + "internal/service/refund/payment_merchant.go" + ], + "domain_state_amount": [ + "pkg/constants/refund.go", + "pkg/wechat/payment_v2_refund.go", + "pkg/fuiou/refund.go", + "pkg/alipay/refund.go" + ], + "store_migration_config": [ + "migrations/000218_add_refund_attempt_and_channel_refund.up.sql", + "migrations/000219_add_wechat_v2_client_cert_credentials.up.sql" + ], + "verification": { + "command": "grep -n 'func RefundCredentialIssue' internal/application/refundchannel/service.go", + "literal_output": [ + "internal/application/refundchannel/service.go:452:func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {" + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "退款权益与订单状态时点", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/callback/wecom/approval/{application_id}", + "/api/admin/refunds/{id}/approve" + ], + "handler_consumer_job": [ + "internal/service/refund/approval_decision.go", + "internal/application/refundchannel/service.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/service/refund/service.go", + "internal/service/package/activation_service.go" + ], + "domain_state_amount": [ + "internal/model/order.go", + "internal/model/refund.go" + ], + "store_migration_config": [ + "migrations/000218_add_refund_attempt_and_channel_refund.up.sql" + ], + "verification": { + "command": "grep -n 'originalRoute := refund.Method' internal/service/refund/approval_decision.go", + "literal_output": [ + "internal/service/refund/approval_decision.go:120:originalRoute := refund.Method == constants.RefundMethodOriginalRoute" + ], + "exit_status": 0 + } + }, + { + "capability": "order-refund-exchange", + "requirement": "退款终态事实与失败分类", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/refunds/{id}", + "constants.TaskTypeRefundChannelRecovery" + ], + "handler_consumer_job": [ + "internal/routes/refund.go", + "internal/handler/admin/refund.go", + "internal/infrastructure/audit/refund_channel.go" + ], + "application_service_query": [ + "internal/service/refund/attempt_query.go", + "internal/exporter/refund_scene.go" + ], + "domain_state_amount": [ + "pkg/constants/refund.go", + "internal/model/refund.go", + "internal/infrastructure/commissiondelivery/event.go" + ], + "store_migration_config": [ + "migrations/000218_add_refund_attempt_and_channel_refund.up.sql" + ], + "verification": { + "command": "grep -n 'func RefundFailureReasonIsDefinitive' pkg/constants/refund.go", + "literal_output": [ + "pkg/constants/refund.go:99:func RefundFailureReasonIsDefinitive(reason string) bool {" + ], + "exit_status": 0 + } } ] diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/.openspec.yaml similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/.openspec.yaml rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/.openspec.yaml diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/design.md b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/design.md similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/design.md rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/design.md diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/proposal.md similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/proposal.md rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/proposal.md diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/specs/merchant-payment-routing/spec.md diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/specs/order-refund-exchange/spec.md diff --git a/openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md b/openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/tasks.md similarity index 100% rename from openspec/changes/add-refund-methods-and-original-route-refunds/tasks.md rename to openspec/changes/archive/2026-09-14-add-refund-methods-and-original-route-refunds/tasks.md diff --git a/openspec/specs/merchant-payment-routing/spec.md b/openspec/specs/merchant-payment-routing/spec.md index 6250c6b..fae1c6f 100644 --- a/openspec/specs/merchant-payment-routing/spec.md +++ b/openspec/specs/merchant-payment-routing/spec.md @@ -7,27 +7,37 @@ ## 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`;`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 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和已有可达的原路退款仍使用该商户当前凭证;服务商无既有退款能力时保持不支持,本能力不得为此新增渠道退款调用或人工开关。 +系统 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: 商户池唯一性与轮询配置 系统 SHALL 为每种支付方式最多启用一个商户池;停用历史池可保留,但不得同时启用多个同支付方式池。商户池成员支付方式 MUST 与池一致,成员按明确顺序排列;金额/笔数方式必须配置 `每轮累计`、`自然日累计` 或 `自然月累计` 统计周期,时间方式必须配置最小为 1 分钟的数值、单位和起始时间。 @@ -47,53 +57,62 @@ - **THEN** 系统拒绝创建新支付单并提示暂无可用商户,不回退到旧综合支付配置 ### Requirement: 新支付商户快照与历史兼容 -C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和已有可达的原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于已有退款路径的能力判定,不提供人工开关,也不得新增渠道退款能力。 -上线迁移在存在唯一当前生效综合支付配置时复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,微信授权字段完整时创建全局微信授权配置;不完整的支付方式不建池,授权字段不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据;新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker;三类后续新支付 MUST 立即只走商户池并冻结路由,无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和已有原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径。 +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** 该支付经过回调、查单或已有原路退款路径 +- **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** 系统保留该兼容基线且不新增富友退款;未第三方实测可以记录,但不得作为本 Change 的实施、验证或归档阻塞 +- **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` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户 - ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 diff --git a/openspec/specs/order-refund-exchange/spec.md b/openspec/specs/order-refund-exchange/spec.md index 48e7446..ddb1414 100644 --- a/openspec/specs/order-refund-exchange/spec.md +++ b/openspec/specs/order-refund-exchange/spec.md @@ -8,7 +8,7 @@ ### Requirement: 订单、退款与换货状态门禁 -系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回流转,只有已退回申请可重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。 +系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回、原路退款处理中、原路退款失败流转,仅已拒绝、已退回和原路退款失败申请可修改并重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。处于待审批、原路退款处理中或原路退款失败的退款申请 MUST 阻止同一订单创建新的退款申请,且 MUST NOT 被再次推进为新的审批实例或第二次渠道退款。 #### Scenario: 重复推进终态 @@ -16,6 +16,16 @@ - **WHEN** 再次审批、发货、完成、取消或重新提交 - **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态 +#### Scenario: 原路退款失败后重提 + +- **WHEN** 退款申请处于原路退款失败状态且调用方在其数据范围内修改材料后重新提交 +- **THEN** 系统新增一条审批尝试记录与一个新的企业微信审批实例并使申请回到待审批,历史尝试与已提交的渠道退款不被覆盖 + +#### Scenario: 企业微信通过后撤销的申请被再次推进 + +- **GIVEN** 退款申请已记录企业微信通过后撤销的审批异常 +- **WHEN** 调用者再次提交或重提该申请 +- **THEN** 系统返回状态冲突,不恢复套餐权益、不取消已提交的渠道退款且不自动重提 ### Requirement: 代理退款查询按所属店铺隔离 系统 SHALL 允许代理账号通过 `GET /api/admin/refunds` 查询其当前所属店铺的全部退款申请,并通过 `GET /api/admin/refunds/{id}` 查询其中任一申请详情,不以申请创建账号作为查询条件。该范围 SHALL 不包含下级代理店铺、其他店铺或未关联店铺的退款申请;代理账号未关联店铺时,列表 SHALL 为空且详情 SHALL 返回不存在。平台和超级管理员的既有退款查询范围 SHALL 保持不变。 @@ -63,6 +73,168 @@ - **WHEN** 退款申请原创建账号不可用,或企业微信退款审批场景不可用 - **THEN** 系统返回相应错误,退款申请保持未关联审批实例,修复条件后可再次发起 +### Requirement: 退款实收金额与方式矩阵 + +系统 SHALL 从来源订单或原成功支付记录派生并冻结权威实收金额,提交人不得填写或修改;无法确定权威实收金额或该金额非正时拒绝创建申请。冻结来源为:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包和后台线下套餐订单取订单实际收款或实际扣款金额。退款金额 MUST 为正分且不得超过冻结实收金额,系统 MUST 在申请创建、审批提交和企业微信通过后的执行前重复校验该上限。一笔订单最多一张最终成功退款申请,成功后该订单不得再申请退款。代理充值预存款业务单不在本期退款范围。 + +可选方式按来源订单的实际支付方式确定,不匹配的方式返回「该订单不支持此退款方式」:线上微信、支付宝或富友支付仅可选原路退款或客户收款信息退款;资产钱包支付仅退回原资产钱包;代理预存款或主钱包支付仅退回原代理钱包;后台线下和员工代收套餐订单仅可选客户收款信息退款。系统 MUST NOT 为本能力新增人工退款方式开关。客户收款信息退款必须同时提供客户收款信息自由文本和至少一个客户凭证附件,且不得复用公司收款方式字典。 + +线上订单原路可退条件按渠道契约在创建、提交和执行前重复预检:冻结实际商户的退款必需凭证不完整、服务商类型不具备退款能力、或原交易超出渠道可退时限时禁用原路并说明原因,只允许客户收款信息退款。富友原交易的原始日期必须回传渠道;未回传时仅支持 30 天内原交易,回传后可退 360 天内原交易,超出该范围的申请不得选择原路。 + +#### Scenario: 无权威实收金额 + +- **WHEN** 来源订单无法取得权威实收金额,或取得的金额非正 +- **THEN** 系统拒绝创建退款申请,不允许提交人以自填金额替代 + +#### Scenario: 提交人试图修改冻结实收金额 + +- **WHEN** 创建或重提请求携带与派生值不同的实收金额 +- **THEN** 系统仍使用派生值作为冻结实收金额,不接受请求值 + +#### Scenario: 钱包订单申请退款 + +- **WHEN** 已支付套餐订单的实际支付方式为资产钱包或代理主钱包 +- **THEN** 系统只提供退回对应原钱包方式,不展示原路或客户收款信息退款 + +#### Scenario: 原路凭证不完整 + +- **GIVEN** 订单为线上支付且其冻结实际商户缺少该服务商类型退款必需凭证 +- **WHEN** 调用者申请退款 +- **THEN** 系统禁用原路退款并返回原因,只允许客户收款信息退款 + +#### Scenario: 富友原交易超出可退时限 + +- **WHEN** 富友原交易的原始支付时间早于可退时限 +- **THEN** 系统禁用原路退款并说明原因,只允许客户收款信息退款 +### Requirement: 企业微信唯一终审与审批尝试重提 + +退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地 MUST NOT 人工通过、拒绝或退回已关联审批实例的申请。每次创建或重提 MUST 新增一条不可变的审批尝试记录,并以该尝试记录作为通用审批业务标识;同一申请每次提交各自持有独立审批实例,历史尝试材料与审批结果不被覆盖。退款单 SHALL 仅保存最新尝试与最新审批实例引用用于展示。 + +同一订单同时至多存在一张待审批、原路退款处理中或原路退款失败的退款申请。企业微信驳回、企业微信关闭、或原路退款明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次重提必须新建审批实例及快照。企业微信提交失败或审批结果未知时申请保持在途,使用既有查询与恢复闭环,不得另建或重提。企业微信通过后撤销时不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。 + +审批终态 MUST 以审批实例标识判别业务归属:先按审批尝试记录标识与审批实例匹配,未命中时按退款申请标识与审批实例匹配以兼容尚未切换到尝试模式的存量申请,两者均不匹配时返回稳定冲突错误,不得回落到任一候选业务单。 + +#### Scenario: 审批通过前结果未知 + +- **WHEN** 企业微信提交成功性或最终结果暂时未知 +- **THEN** 申请保持在途且订单不得创建第二张活动申请,系统通过既有恢复机制确认结果 + +#### 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** 退款保持原路退款处理中,套餐权益不恢复,系统不得再次提交退款,仅由查询恢复回填结果 + +#### 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** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。