# PRD：UR#48 按资产类型限制 C 端支付方式

Status: ready-for-agent

---

## Problem Statement

当前 C 端代码已经存在“卡仅允许支付宝、设备仅允许微信、钱包均可使用”的校验草稿，但相关校验被注释，支付页面和后端实际上没有统一读取一份可配置规则。用户可以绕过前端直接提交资产不允许的支付方式。

当前普通套餐订单还在创建时不保存支付方式，到后续 `/pay` 才临时选择；强充却必须在创建流程中立即决定拉起微信还是支付宝。这使普通下单和强充对同一个 `payment_method` 的含义不一致，也无法保证订单创建后支付方式不被替换。

## Solution

通过公共系统配置分别维护卡和设备允许的 C 端支付方式，初始默认卡为支付宝与钱包、设备为微信与钱包，钱包始终存在且不可取消。C 端资产信息返回 `allowed_payment_methods`，页面只展示后端返回的方式。

`POST /api/c/v1/orders/create` 必须提交 `payment_method`，后端在创建任何订单或强充单前按资产配置校验并固化选择。普通套餐订单后续支付只能执行订单上已经保存的方式，不能在 `/pay` 更换；强充则在创建流程中直接用该方式拉起相应第三方支付。创建与实际支付两个时点都重新校验当前配置。

## User Stories

1. 作为卡用户，我默认只看到支付宝和钱包，不会误选设备专用的微信渠道。
2. 作为设备用户，我默认只看到微信和钱包，不会误选卡专用的支付宝渠道。
3. 作为用户，我希望创建订单时选定支付方式，发生强充时系统能立即拉起正确渠道。
4. 作为用户，我希望待支付订单的支付方式保持不变，不会在支付时被换成另一种方式。
5. 作为平台超级管理员，我希望通过受控复选框调整卡、设备的允许方式，同时不能移除钱包。
6. 作为安全与审计人员，我希望前端隐藏和后端强校验使用同一规则，配置损坏时也不会放开全部渠道。

## Implementation Decisions

### 支付方式规则

- C 端业务支付方式固定为 `wallet`、`wechat`、`alipay`；`offline`、`bank` 等后台或线下方式不能加入本配置，也不能通过 C 端接口提交。
- 初始化安全默认值：
  - 卡资产：`["alipay", "wallet"]`。
  - 设备资产：`["wechat", "wallet"]`。
- `wallet` 对卡和设备始终允许。后台页面固定勾选且不可取消，后端更新配置时再次强制校验。
- 超级管理员可以在对应资产集合中增加或移除 `wechat`、`alipay`，因此默认限制不是写死的永久单通道规则。
- 允许集合只表达资产类型的业务许可，不代表渠道此刻健康或支付参数一定完整。真正拉起微信或支付宝时仍执行已有渠道配置、OpenID、签名和可用性校验；失败时不得自动切换支付方式。

### 公共系统配置

- 依赖公共 `tb_system_config` 基础能力，使用两个受控配置：
  - `c2b.payment.card_allowed_methods`，模块 `c2b.payment`。
  - `c2b.payment.device_allowed_methods`，模块 `c2b.payment`。
- `config_value` 继续按系统配置契约保存 JSON 数组文本；业务代码必须通过系统配置读取能力解析，不能在 Handler 中直接读表或自行解析另一套规则。
- 合法配置必须是非空数组、元素唯一、只包含 `wallet|wechat|alipay` 且包含 `wallet`。空数组、未知值、重复值、非法 JSON、缺少钱包或记录缺失均视为配置异常。
- 配置异常时记录错误并使用对应资产的完整安全默认集合，禁止“解析失败则允许全部”，也不能把部分损坏值与默认值随意拼接。
- 配置读取使用真实 Redis 缓存，TTL 为 5 分钟；Key 由 `pkg/constants/redis.go` 的函数生成。更新数据库成功后立即删除对应 Key，下次读取回源 PostgreSQL。
- 只有平台超级管理员可以读取管理页并更新这两个值；普通平台账号、代理、企业和 C 端均不得修改。
- `GET /api/admin/system/config?module=c2b.payment` 复用公共配置列表；`PUT /api/admin/system/config/{config_key}` 复用公共更新接口。未知 Key 默认不可写，不能借通用接口创建任意配置。
- 管理页面必须将已知支付配置渲染为复选框，不向运营人员暴露 JSON 编辑框；钱包显示为已勾选且禁用。

### C 端资产信息契约

- `GET /api/c/v1/asset/info` 增加：

```json
{
  "allowed_payment_methods": ["alipay", "wallet"]
}
```

- 返回值根据已经解析并确认归属的资产类型计算，顺序稳定为 `wallet`、`wechat`、`alipay` 中配置允许项的产品展示顺序；前后端冻结统一顺序，不能依赖数据库 JSON 原始顺序产生界面抖动。
- 未识别资产类型、无权资产或资产不存在沿用现有安全错误，不为探测配置而返回允许方式。
- 该字段是页面展示提示，不是授权凭证。创建充值、创建套餐订单和执行支付都必须自行重新读取并校验。

### 套餐订单创建契约

- `POST /api/c/v1/orders/create` 的 `payment_method` 从“强充可选传”改为所有下单必填，允许值为 `wallet|wechat|alipay`。
- 后端完成资产解析、归属和套餐购买校验后，必须在产生订单、充值单、支付单或调用第三方前，按实际资产类型校验 `payment_method` 是否仍在当前允许集合。
- 普通套餐下单仍然是两阶段流程：创建接口生成待支付套餐订单，不在普通场景自动拉起第三方支付；但创建时必须把所选方式保存到订单 `payment_method`，作为不可变支付方式快照。
- 创建接口成功响应和订单详情返回已选 `payment_method`，让页面明确后续将执行哪一种方式。
- 若选择 `wechat`，普通下单时可以不提前提交 `app_type`，因为此时没有拉起支付；后续 `/pay` 执行微信支付时必须提交合法 `app_type`。
- 若创建流程判定需要强充，必须立即按所选方式执行：
  - `wechat`：创建强充充值单及支付单，并按 `app_type` 拉起微信；此时 `app_type` 必填。
  - `alipay`：创建强充充值单及支付单并返回支付宝支付链接，不要求 `app_type`。
  - `wallet`：明确拒绝并提示“该套餐需要先充值，请选择当前资产允许的微信或支付宝方式”，不创建任何套餐订单、充值单或支付单。钱包不能给自身充值。
- 若资产配置只剩钱包，而所选套餐要求强充，则该套餐暂时无法完成购买；系统不得绕过配置启用第三方方式，也不得把钱包强充伪装为普通钱包支付。

### 待支付订单支付契约

- `POST /api/c/v1/orders/{id}/pay` 不再让客户端选择或修改 `payment_method`，必须读取订单创建时保存的方式。
- 新契约请求体只携带执行该固定方式所需的附加参数：订单方式为微信时 `app_type` 必填；钱包或支付宝不需要 `app_type`。
- 如果前后端过渡期间仍收到 `payment_method` 字段，只能在其与订单快照完全相同时兼容执行；不同则返回冲突，绝不能覆盖订单字段。过渡结束后从 DTO 和 API 文档移除该字段。
- 支付前再次根据订单关联的实际资产类型读取最新允许集合：
  - 快照方式仍允许：继续执行。
  - 快照方式已经被管理员禁用：拒绝支付，不自动换渠道；用户取消原订单后用新的允许方式重新创建。
- 支付方式是订单不可变业务字段。任何 Service、支付回调或重试流程都不得修改待支付订单的 `payment_method`。
- 支付记录的 `payment_method` 必须与订单快照一致。已有同订单、同方式的有效待支付记录按现有幂等规则复用；不得复用另一支付方式的支付记录。

### C 端主动取消

- 新增 `POST /api/c/v1/orders/{id}/cancel`，只允许当前 C 端用户取消属于自己的待支付套餐订单。
- 订单不存在和不属于当前用户使用统一安全错误，防止探测其他用户订单；已支付、已退款等非待支付状态不得取消。
- 取消使用 `WHERE id=? AND payment_status=待支付` 条件更新，保证与支付并发时只有一个终态成功。已经取消的同一订单重复请求按幂等成功返回当前状态。
- 取消成功后，用户才可以为相同资产和套餐重新选择支付方式创建订单；不能仅在旧订单上修改方式。
- 取消时复用现有订单取消用例，正确关闭或作废该订单尚未完成的支付记录，并释放现有流程中已经冻结的资源；不得复制一套状态流转。
- 现有 30 分钟待支付订单自动取消任务继续保留，作为用户未主动取消时的兜底。

### 普通资产钱包充值

- C 端创建资产钱包充值单的接口也必须按资产允许集合校验所选第三方方式，不能只在套餐支付处校验。
- 钱包充值接口本身只支持 `wechat|alipay`。页面展示方式应取“资产允许集合”与“充值接口支持集合”的交集，因此即使 `wallet` 始终在资产集合中，也绝不能显示为给钱包充值的支付手段。
- 强充和普通钱包充值使用相同的资产支付许可判断，但保留各自现有金额、归属、渠道和幂等规则。
- 若交集为空，充值页面禁止提交并明确提示当前资产没有可用充值渠道；后端仍必须拒绝构造请求。

### 配置变更、并发与幂等

- 配置采用支付动作发生时的最新有效值，不给订单保存配置版本快照。创建成功后配置发生变化，后续 `/pay` 按最新配置复核，因此可能要求用户取消并重新下单。
- 配置更新的数据库写入和统一 Audit Event 在同一事务完成；事务提交后删除 Redis 缓存。缓存删除失败需要记录错误并进行有限重试/告警，不能谎称各节点已经立即生效。
- 订单创建的现有 Redis 防重业务摘要必须包含所选 `payment_method`，或在命中旧摘要时比较完整请求摘要：相同请求返回同一结果，不同方式不能被误判为同一幂等请求。
- 同一资产、套餐组合已有待支付订单时，试图改用另一方式必须返回冲突并提示先取消原订单；取消后才能按新方式重新创建。不能靠更换方式并发生成多个有效待支付订单。
- Redis 只承担短时防重和缓存，订单支付方式与配置值的权威事实仍在 PostgreSQL。

### 架构、错误与审计

- 公共系统配置采用简单 Application 事务脚本；支付方式解析与校验提供一个可复用的应用能力，供资产信息 Query、套餐下单、订单支付、普通充值和强充调用。
- 禁止在各 Handler 复制卡/设备默认数组或各写一套 `if`；代码内安全默认值和合法枚举统一放在 `pkg/constants/` 并使用中文注释。
- 参数格式非法返回统一参数错误；方式合法但不在资产允许集合时返回统一禁止错误及中文提示；底层 Redis、PostgreSQL、JSON 或渠道错误只写日志并转换为统一错误。
- 配置更新必须记录统一 Audit Event，包含 Key、变更前后允许集合、操作者和请求 ID；支付拒绝及支付执行继续按订单/支付统一审计方案记录。
- 不在日志、访问日志或审计中保存支付密钥、完整渠道请求签名等敏感信息。

### 前端交互

- 支付页面完全根据 `allowed_payment_methods` 展示资产可选方式，不维护卡/设备固定规则副本。
- 创建套餐订单前必须选择一种方式，并随 `/orders/create` 提交；创建成功后在待支付订单上显示“已选支付方式”，不再提供切换控件。
- 如需切换，用户必须先明确取消待支付订单，再重新选择方式创建；前端不能仅修改本地选中项后调用 `/pay`。
- 强充场景选微信或支付宝后由创建接口直接返回对应支付参数；选钱包被拒绝时保留套餐选择，并引导用户改选当前资产允许的第三方方式。
- 普通充值页面不展示钱包选项，只展示允许集合与 `wechat|alipay` 的交集。
- 后台更新支付方式后提示配置已保存；C 端重新进入或刷新资产页获取最新集合。正在展示的旧集合不影响后端再次校验。

### 发布与回滚

- 先发布 `tb_system_config`、初始化值、Redis 缓存与后端双重校验，再同批发布创建订单必填支付方式和前端固定方式交互；不能只发布前端隐藏。
- 上线前核查历史待支付订单中空 `payment_method` 的数量。历史空值订单不能猜测支付方式，应要求取消并按新契约重建；已支付历史订单保持原样。
- 前后端同批切换 `/orders/create` 必填字段和 `/pay` 不可换方式契约。发布窗口应阻断旧前端继续创建缺少方式的新订单。
- 回滚应用时保留系统配置、订单快照、支付记录和审计事实；不得通过回滚迁移删除已产生数据。

## 公共能力发布依赖

- 新增 05 号发布票，阻塞于公共基础 12 号票、全局审计 20 号系统配置 Adapter 接入票和全局审计 19 号切换票。
- UR#48 负责注册正式支付配置 Key；系统配置更新的 Audit Event 与配置事实同事务，支付方式以订单快照为业务事实。公共配置接口存在不等于前端现在就新增配置页面。

## Testing Decisions

- 配置单元测试覆盖卡/设备默认集合、合法自定义、钱包不可移除、空数组、未知值、重复值、非法 JSON、缺失记录和未知资产类型。
- 使用真实测试 PostgreSQL 与 Redis 的集成测试验证初始化值、5 分钟缓存、更新后立即失效、缓存故障回源策略及配置异常安全默认；不以完全不连接 Redis 的假环境代替关键验收。
- 权限测试验证仅平台超级管理员能修改配置，代理、普通平台账号、企业及 C 端均不能越权读取管理信息或更新。
- 资产信息测试验证卡、设备分别返回最新允许集合，配置异常回退安全默认，资产不存在或越权不泄露配置。
- 创建订单测试覆盖三种方式必填校验、卡/设备允许与禁止组合、方式写入订单、普通订单不立即拉起支付，以及提交前配置变化。
- 强充测试覆盖微信立即拉起且要求 `app_type`、支付宝立即返回链接、钱包明确拒绝、配置只有钱包时不可强充，以及失败时不残留订单/充值单/支付单。
- `/pay` 测试覆盖读取订单快照、相同过渡字段兼容、不同字段冲突、请求不传方式、微信附加参数、管理员禁用后拒绝、取消重建后使用新方式。
- 支付记录测试验证其方式始终等于订单快照，不跨方式复用待支付记录，回调也不能改变订单方式。
- 普通钱包充值测试覆盖允许集合交集、钱包永不作为充值渠道、无第三方交集时拒绝以及前端构造非法方式时后端兜底。
- 幂等与并发测试覆盖同请求同方式复用、同资产套餐不同方式冲突、待支付订单取消后重建，以及 Redis 短时键不能取代 PostgreSQL 权威状态。
- C 端取消测试覆盖本人待支付订单成功、他人订单安全拒绝、非待支付拒绝、重复取消幂等、取消与支付并发只产生一个终态，以及取消后可按新方式重新创建。
- HTTP 集成测试穿过 Fiber、认证、Validator、Application、GORM/PostgreSQL、真实 Redis 与统一响应，验证接口文档与运行时契约一致。
- 前端验收覆盖资产支付页、普通两阶段支付、强充一步拉起、取消后换方式、配置刷新、空交集和后台受控复选框。

## Out of Scope

- 不改变代理后台订单、线下支付或银行转账规则。
- 不允许 `/pay` 修改订单创建时选定的支付方式。
- 不让钱包给自身充值，也不绕过强充要求直接钱包购包。
- 不自动在微信、支付宝、钱包之间降级或切换。
- 不根据渠道实时健康状态自动修改 `allowed_payment_methods`。
- 不为每张卡或每台设备保存独立支付方式配置；本期粒度只有资产类型。
- 不把系统配置改造成可任意新增 Key 的无约束配置中心。

## Further Notes

- 当前 `ClientCreateOrderRequest.payment_method` 只允许微信/支付宝且仅强充必传，普通订单的 `Order.PaymentMethod` 创建时为空；实现需要按本规格改为创建必填并固化。
- 当前 `/orders/{id}/pay` 必填 `payment_method`，实现后它不再是可选业务决策，只能使用订单快照。
- 当前代码中卡/设备第三方支付限制已经以注释存在，但恢复时必须改为公共配置校验，不能简单解除注释并重新写死。
- 用户已明确确认：支付方式在创建订单时决定，后续不允许更换。
