7.7 KiB
7.7 KiB
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 要手工抄录的「支付事实」。
因此登记行混合了两类事实:
- 权益事实(订阅:编号、期间、总额度)——来自钱包页,客观
- 金钱事实(谁付了多少)——来自支付凭证/转账,社交层,可与权益不同日(预付、分期、事后补账)
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(拆表),展示层合并流水。
理由摘要:
- 领域本来就是两种东西——上游也是。单表同构是登记方便,不是概念等价。
- 时间轴不同是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
- 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
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) |
人工(抄钱包页) |
迁移要点(若采纳):
- 存量
kind=subscription行 →subscriptions;kind=topup→topups。 - 存量订阅行的
paid_at:若当时填的是登记日 → 迁到created_at,paid_at置空(或作者按凭证补真付款日)。 quota_usd换算锚点:优先paid_at(有则),否则created_at或period_start——契约里写死优先级。- 日志
subscription_id直连改为指向subscriptions.subscription_id(语义不变)。 - 纯用量模式(零登记)不受影响。
方案 A 的适用情形:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
5. 与既有裁决的关系
| 裁决 | 拆表后 |
|---|---|
| R7 充值与订阅同构(登记形态) | 展示/哲学同构(只存事实、无分摊)不变;物理模型分表 |
R8 subscription_id 直连 |
不变,指向订阅表 |
| R9 币种无关、只存事实 | 不变 |
| 「payments」表名 | 订阅表可名 subscriptions;充值表 topups;若保留对外「支付记录」只作 UI 名 |
已采纳方案 B(2026-10-09):表设计见 subscriptions表字段设计.md / topups表字段设计.md;契约与交互随拆表修订。