Files
skycurtain c32548e598 fix(web): 支付表列与表单语义校准
充值金额/实付金额文案与 dataIndex;期间两行;备注列收窄;
选用弹层 booting 不闪登录;订阅 paid_at 不默认当前时间。
2026-10-09 22:41:04 +08:00

142 lines
7.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# payments 建模评估:充值与订阅要不要拆表
> 对「一张 `payments` 同构装下订阅 + 充值」的全面复盘。触发点:`paid_at` 语义在订阅行上更像 `created_at`,按付费时间筛会在**后补录**时丢行。**结论:方案 B 拆表已采纳**(2026-10-09)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-09,作者采纳方案 B) |
| 关联 | `subscriptions表字段设计.md` · `topups表字段设计.md`(替代原 `payments` 单表)· `NewAPI-接口范围.md` §2.⑨ §4.3 · `需求清单.md` R7/R8/R9 · `usage_logs表字段设计.md` 3.6 |
| 依据 | 上游订阅实体字段(钱包页 / `GET /api/subscription/self`)· 日志 `billing_source` / `subscription_id` · 现行登记与筛选交互 |
---
## 1. 领域里其实有两种东西
NewAPI 侧(接口参考 §4.3、日志 `other`):
| 上游概念 | 本质 | 时间轴 | 与消耗的关系 |
|----------|------|--------|--------------|
| **订阅**(`all_subscriptions[].id`) | **权益包**:`start_time`/`end_time` 覆盖期 + `amount_total`/`amount_used` 额度预算 + `created_at` | **期间** | 日志 `billing_source=subscription` + `subscription_id` 直连 |
| **充值**(`LogTypeTopup=1`) | **入账事件**:钱 → 钱包额度;账号级、无 token_id | **事件时刻** | 日志 `billing_source=wallet`,**无**逐笔 `subscription_id`(余额池) |
注意:上游**没有**「订阅付款单」——订阅的 `created_at` 是权益创建时刻,不是「作者何时把钱付给组织者」。后者只存在于社交层,正是 `payments` 要手工抄录的「支付事实」。
因此登记行混合了两类事实:
1. **权益事实**(订阅:编号、期间、总额度)——来自钱包页,客观
2. **金钱事实**(谁付了多少)——来自支付凭证/转账,社交层,**可与权益不同日**(预付、分期、事后补账)
## 2. 现行单表哪里拧着
`payments` 九列里约一半是「订阅专用可空」:
| 列 | 订阅 | 充值 | 问题 |
|----|------|------|------|
| `kind` | `subscription` | `topup` | 判别式列(合法但胖) |
| `paid_at` | 混用:真实付款日 **或** 登记日 | 真实充值日 | **语义分叉**(本次痛点) |
| `paid_amount` | 套餐价(可能与期间不同日) | 充值额 | 尚可 |
| `period_start/end` | 必填 | NULL | 可空列 |
| `subscription_id` | 必填 | NULL | 可空列 |
| `quota_granted` | 套餐总额度 | 充值换得额度 | 语义都是「换得的量」,可共用 |
| `remark` | ✓ | ✓ | |
| (无) | **登记时刻** | **登记时刻** | 补录/审计缺失 |
连带后果:
- **筛选**:按 `paid_at` 筛「这段时间的账」→ 后补录的订阅落在补录日,对不上用量窗口;按期间筛又对不上充值(无期间)。
- **QPU 换算锚点**:契约写「按 `paid_at` 换算 `quota_usd`」——若 `paid_at` 在订阅行上是登记日,换算率锚点也跟着漂。
- **校验**:kind 分支必填(契约已做),但类型系统层仍是「全可空 + 运行时 if」。
## 3. 三个方案
### 方案 A:单表保留,时间语义拆开(小改)
```
payments
kind, paid_amount, remark, quota_granted ← 共用
created_at timestamptz NOT NULL ← 新增:登记入库时刻
paid_at timestamptz? NULLABLE ← 仅「钱出手」时刻;订阅可空
period_start/end, subscription_id ← 仅订阅
```
| 筛选口径 | 规则 |
|----------|------|
| 登记时间 | `created_at`(后补录可查) |
| 充值 | `paid_at ∈ [from,to)` |
| 订阅 | 期间重叠:`period_start < to AND (period_end IS NULL OR period_end > from)` |
- **利**:迁移小(加一列 + 语义清洗存量 `paid_at`);API/DTO 形状基本不动。
- **弊**:仍是判别式胖行;「订阅的付款日」与「权益期间」继续挤在同一行心智里。
### 方案 B:拆两张表(对齐上游)
```
subscriptions topups
id PK id PK
subscription_id UNIQUE upstream_id? ← 上游充值单 id
period_start NOT NULL amount NOT NULL ← 平台计费金额(充值额)
period_end NULL money NOT NULL ← 实付
quota_granted NOT NULL trade_no / payment_* ← 渠道事实
paid_amount NULL paid_at NOT NULL ← = 上游 complete_time(唯一事件时刻)
paid_at NULL status
remark remark
created_at NOT NULL created_at NOT NULL
```
(2026-10-09 实测修正:上游充值单**无 `quota_granted`**——`amount × qpu` 派生;事件时刻**只留一条** `paid_at`=`complete_time`,`create_time` 不入库。)
- **利**:字段全非空(或意义明确);时间轴各归各;与 NewAPI「权益 vs 入账」同构;筛选规则按表定义,无 if-else。
- **弊**:登记 UI 两种表单(可仍一页两 Tab);「统一支付流水」要 UNION 视图;迁移把现表按 `kind` 拆走。
### 方案 C:三张表(金钱流水与权益彻底解耦)——**不建议**
`money_events` + `subscriptions` + 关联。年登记量个位数~十位数,过重。
## 4. 建议
**采用方案 B(拆表),展示层合并流水。**
理由摘要:
1. **领域本来就是两种东西**——上游也是。单表同构是登记方便,不是概念等价。
2. **时间轴不同**是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
3. 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
4. `paid_at` 问题在 B 里消失:`topups.paid_at` 恒为事件;`subscriptions.paid_at` 明确为「套餐付款日,可空」;`created_at` 两表都有。
**统一入口**(交互不变感):
- 管理面「支付记录」页 = 两 Tab(订阅 / 充值)或一张 UNION 列表(按时间轴分别标注「期间 / 付费 / 登记」)。
- 成员「支付参数」页同源两集,R12 弹窗仍只挂订阅。
- 「该时段涉及」:订阅看期间重叠 ∪ 进行中;充值看 `paid_at`;补录审计看 `created_at`。
**字段语义钉死(两表通用原则)**:
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」的时刻 | 系统 |
| `paid_at` | 钱真实出手时刻(凭证上的时间) | 人工;不知则空 |
| `period_*` | 用量覆盖期 `[start, end)` | 人工(抄钱包页) |
**迁移要点**(若采纳):
1. 存量 `kind=subscription` 行 → `subscriptions`;`kind=topup` → `topups`。
2. 存量订阅行的 `paid_at`:若当时填的是登记日 → 迁到 `created_at`,`paid_at` 置空(或作者按凭证补真付款日)。
3. `quota_usd` 换算锚点:优先 `paid_at`(有则),否则 `created_at` 或 `period_start`——契约里写死优先级。
4. 日志 `subscription_id` 直连改为指向 `subscriptions.subscription_id`(语义不变)。
5. 纯用量模式(零登记)不受影响。
**方案 A 的适用情形**:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
## 5. 与既有裁决的关系
| 裁决 | 拆表后 |
|------|--------|
| R7 充值与订阅同构(登记形态) | **展示/哲学同构**(只存事实、无分摊)不变;**物理模型分表** |
| R8 `subscription_id` 直连 | 不变,指向订阅表 |
| R9 币种无关、只存事实 | 不变 |
| 「payments」表名 | 订阅表可名 `subscriptions`;充值表 `topups`;若保留对外「支付记录」只作 UI 名 |
---
**已采纳方案 B(2026-10-09)**:表设计见 `subscriptions表字段设计.md` / `topups表字段设计.md`;契约与交互随拆表修订。