docs: payments 拆表设计定稿与契约/交互对齐
新增建模评估与 subscriptions/topups 表设计;删除 payments 单表篇; 契约、支付交互、需求清单与文档地图同步。
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# 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`;契约与交互随拆表修订。
|
||||
Reference in New Issue
Block a user