如何设计订单和订阅表
订单和订阅表是产品商业化的核心基础设施。无论是 AI 工具按次扣费、SaaS 订阅按月扣款,还是一次性购买解锁功能,底层都需要一套可靠的订单与订阅数据模型来支撑。设计不当会导致计费错误、状态不一致、用户投诉,甚至合规风险。
本文聚焦 AI 产品出海场景,系统讲解订单表、订阅表、支付记录表的字段设计,Stripe 集成中的 Webhook 处理,以及完整的状态流转方案。
订单表设计
订单是交易的基本单元。AI 产品出海的订单通常分为两类:一次性订单(如购买 token 包、解锁功能)和订阅订单(周期性扣费产生的订单)。无论哪种类型,订单表都需要记录「谁买了什么、花了多少钱、当前处于什么状态」。
核心字段
订单表的设计需要考虑以下关键信息维度:
| 字段 | 类型 | 说明 |
|---|---|---|
id | UUID / BIGSERIAL | 主键,建议使用 UUID 避免对外暴露业务量 |
order_no | VARCHAR(64) | 订单流水号,对外展示用,需唯一索引 |
user_id | UUID | 关联用户表 |
order_type | ENUM | 订单类型:one_time、subscription、top_up |
status | ENUM | 订单状态:pending、paid、failed、cancelled、refunded |
currency | CHAR(3) | 货币代码,如 USD、EUR,遵循 ISO 4217 |
amount_total | INTEGER | 总金额,以最小货币单位存储(美分),避免浮点精度问题 |
amount_tax | INTEGER | 税额(美分) |
discount_amount | INTEGER | 折扣金额(美分) |
description | TEXT | 订单描述,如「Pro Plan - 月付」 |
metadata | JSONB | 扩展字段,存放商品快照、促销码等灵活信息 |
stripe_payment_intent_id | VARCHAR(255) | 关联 Stripe PaymentIntent |
stripe_invoice_id | VARCHAR(255) | 关联 Stripe Invoice(订阅场景) |
paid_at | TIMESTAMPTZ | 支付成功时间 |
created_at | TIMESTAMPTZ | 创建时间 |
updated_at | TIMESTAMPTZ | 更新时间 |
设计要点
金额存储用整数。这是支付系统的第一条铁律——永远不要用 DECIMAL 或 FLOAT 存金额。以美分为单位存储为 INTEGER,展示时再除以 100。这避免了 0.1 + 0.2 ≠ 0.3 的经典浮点陷阱。
订单号要有业务规则。常见的格式是 日期 + 序列号,如 20260701000001。流水号用唯一索引约束,自增主键不对外暴露。这样既方便客服快速定位,又不泄露业务量。
metadata 字段是安全阀。AI 产品的订单内容可能涉及 token 数量、模型版本、功能权限等。不要为每种商品类型建独立字段,用 JSONB 存放商品快照更灵活。但注意:订单创建后 metadata 应该是不可变的(immutable),它代表的是交易时的快照。
订阅表设计
订阅是 SaaS 和 AI 产品的主要商业模式。一个用户可能同时持有多个订阅(比如基础版 + API 调用包),订阅表需要清晰记录「当前生效的计划是什么、什么时候到期、是否自动续费」。
核心字段
| 字段 | 类型 | 说明 |
|---|---|---|
id | UUID / BIGSERIAL | 主键 |
user_id | UUID | 关联用户表 |
plan_id | UUID | 关联计划表 |
status | ENUM | 状态:trialing、active、past_due、canceled、paused、expired |
billing_cycle | ENUM | 计费周期:monthly、yearly、usage_based |
current_period_start | TIMESTAMPTZ | 当前计费周期开始时间 |
current_period_end | TIMESTAMPTZ | 当前计费周期结束时间 |
trial_start | TIMESTAMPTZ | 试用期开始时间(可为空) |
trial_end | TIMESTAMPTZ | 试用期结束时间(可为空) |
cancel_at_period_end | BOOLEAN | 是否在周期结束时取消,默认 false |
canceled_at | TIMESTAMPTZ | 用户发起取消的时间 |
auto_renew | BOOLEAN | 是否自动续费,默认 true |
stripe_subscription_id | VARCHAR(255) | 关联 Stripe Subscription |
stripe_customer_id | VARCHAR(255) | 关联 Stripe Customer |
created_at | TIMESTAMPTZ | 创建时间 |
updated_at | TIMESTAMPTZ | 更新时间 |
设计要点
一个用户允许多个订阅。不要假设「一个用户只有一个订阅」。AI 产品常见的场景是:基础订阅 + API 调用量包 + 额外存储空间。订阅表与用户表应该是一对多关系。
计划变更要记历史。用户可能从 Pro 升级到 Enterprise,或从月付改为年付。建议用一张 subscription_history 表记录每次变更:
CREATE TABLE subscription_history (
id BIGSERIAL PRIMARY KEY,
subscription_id UUID REFERENCES subscriptions(id),
previous_plan_id UUID,
new_plan_id UUID,
changed_at TIMESTAMPTZ DEFAULT now(),
reason VARCHAR(100) -- 'upgrade', 'downgrade', 'plan_change'
);这张表对于收入分析、用户行为追踪和客服查询都非常有价值。
试用期需要独立字段。试用期和普通付费周期在权限控制上可能不同(比如试用期限制 API 调用次数),将 trial_start 和 trial_end 单独列出,比混在 current_period_start 里更清晰。
支付记录表设计
订单表记录「应该收多少钱」,支付记录表记录「实际收了多少钱」。一笔订单可能对应多次支付尝试(比如首次扣款失败后自动重试),支付记录表需要忠实记录每一次尝试。
核心字段
| 字段 | 类型 | 说明 |
|---|---|---|
id | UUID / BIGSERIAL | 主键 |
order_id | UUID | 关联订单表 |
payment_intent_id | VARCHAR(255) | Stripe PaymentIntent ID |
status | ENUM | 状态:pending、succeeded、failed、canceled |
amount | INTEGER | 支付金额(美分) |
currency | CHAR(3) | 货币代码 |
payment_method | VARCHAR(100) | 支付方式:card、alipay、wechat_pay |
stripe_payment_method_id | VARCHAR(255) | Stripe 支付方式 ID |
failure_code | VARCHAR(100) | 失败代码,如 card_declined、expired_card |
failure_message | TEXT | 失败详情 |
retry_count | INTEGER | 重试次数,默认 0 |
processed_at | TIMESTAMPTZ | 处理完成时间 |
created_at | TIMESTAMPTZ | 创建时间 |
设计要点
支付记录与订单是一对多关系。一次扣款失败后,系统可能自动重试 3 次。每次尝试都应该生成一条支付记录,而不是更新已有记录。这样做的好处是:完整的审计轨迹、准确的失败分析、方便与 Stripe Dashboard 逐条对账。
失败信息要完整记录。failure_code 和 failure_message 直接取自 Stripe 返回的 PaymentIntent.last_payment_error 对象。这些信息对于自动重试策略(比如 card_declined 不重试,processing_error 可以重试)和用户体验(给用户展示具体的失败原因)都至关重要。
金额单位保持一致。支付记录表的 amount 字段也必须以最小货币单位存储。跨表金额比较时,统一单位能避免大量排查工作。
Stripe 集成
对于 AI 产品出海,Stripe 是事实上的标准支付基础设施。集成 Stripe 的核心挑战不是发起支付,而是保持本地数据库与 Stripe 状态的同步。
数据同步原则
本地数据库是 source of truth 的「投影」。Stripe 存储的是支付维度的真实状态,本地数据库存储的是业务维度的状态。两者之间通过 Webhook 保持同步。当本地查询需要实时数据时,可以选择性地向 Stripe API 发起补充查询,但常规业务逻辑不应依赖实时 API 调用。
Webhook 处理
Stripe 通过 Webhook 通知你支付状态的变化。AI 产品出海场景中最关键的 Webhook 事件包括:
| 事件 | 触发时机 | 本地处理 |
|---|---|---|
checkout.session.completed | 用户完成 Checkout 支付 | 创建订单,激活订阅 |
invoice.paid | 订阅续费扣款成功 | 更新订单状态为 paid,延长订阅周期 |
invoice.payment_failed | 订阅续费扣款失败 | 标记订单为 failed,更新订阅为 past_due |
customer.subscription.updated | 订阅信息变更 | 同步订阅计划、周期、状态 |
customer.subscription.deleted | 订阅被取消或过期 | 更新订阅状态为 canceled,回收权限 |
payment_intent.succeeded | PaymentIntent 支付成功 | 更新支付记录为 succeeded |
payment_intent.payment_failed | PaymentIntent 支付失败 | 更新支付记录为 failed,记录失败原因 |
Webhook 处理的核心原则
幂等性(Idempotency)。Stripe 的 Webhook 可能重复投递同一个事件。处理逻辑必须是幂等的——同一个事件处理 1 次和处理 10 次的效果相同。常见做法是在处理前检查 Webhook 事件的 id 是否已经处理过:
CREATE TABLE webhook_events (
id VARCHAR(255) PRIMARY KEY, -- Stripe event ID
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now(),
created_at TIMESTAMPTZ DEFAULT now()
);每次收到 Webhook 先 INSERT,如果主键冲突说明已处理过,直接返回 200。
签名验证。必须在服务端验证 Webhook 请求的签名,确认请求确实来自 Stripe 而非伪造。Stripe SDK 提供了 webhooks.constructEvent() 方法,用 Webhook Secret 验证 Stripe-Signature 请求头。
异步处理。Webhook 处理器应该在收到事件后尽快返回 200(Stripe 要求 5 秒内响应),业务逻辑放到后台队列异步处理。如果处理失败,Stripe 会自动重试(最多 3 天内重试数十次)。
状态流转设计
订单和订阅的状态流转是整个系统最容易出错的部分。状态之间的转换必须有明确的规则,不允许任意跳转。
订单状态流转
订阅状态流转
状态同步映射
Stripe 的 Subscription 状态与本地数据库需要做一层映射:
| Stripe 状态 | 本地状态 | 说明 |
|---|---|---|
trialing | trialing | 试用中 |
active | active | 正常生效 |
past_due | past_due | 续费失败,进入催款流程 |
canceled | canceled | 已取消 |
unpaid | past_due | 未付款,按业务需求映射 |
paused | paused | 已暂停 |
incomplete | pending | 首次支付未完成 |
incomplete_expired | cancelled | 首付超时过期 |
案例
案例 1:AI 写作工具的订阅与按量计费
一款 AI 写作工具提供两种收费方式:Pro 订阅($19/月,含 10 万 token)和额外 token 包($5 / 5 万 token)。
用户在 7 月 1 日订阅 Pro 月付计划:
subscriptions 表:
user_id: user_001
plan_id: plan_pro_monthly
status: active
billing_cycle: monthly
current_period_start: 2026-07-01
current_period_end: 2026-08-01
stripe_subscription_id: sub_xxx
orders 表:
order_no: 20260701000001
order_type: subscription
status: paid
amount_total: 1900 -- $19.00
stripe_invoice_id: inv_xxx
paid_at: 2026-07-01T10:30:00Z
7 月 15 日,用户 token 用完,购买了 2 个额外 token 包:
orders 表:
order_no: 20260715000002
order_type: top_up
status: paid
amount_total: 1000 -- $10.00(2 × $5)
metadata: { "items": [{"product": "token_pack_50k", "quantity": 2}] }
stripe_payment_intent_id: pi_yyy
到 8 月 1 日续费时,Stripe 发出 invoice.paid Webhook,系统更新订阅周期到 9 月 1 日,同时生成一笔新订单。
案例 2:续费失败的完整处理链路
8 月 1 日续费扣款失败,完整的状态流转如下:
- Stripe 发出
invoice.payment_failedWebhook - 系统将订单状态从
pending更新为failed,订阅状态从active更新为past_due - 支付记录表写入一条
failed记录,failure_code: card_declined - Stripe 3 天后自动重试,发出第二次
invoice.payment_failed - 支付记录表再写入一条
failed记录,retry_count: 1 - Stripe 再次重试成功,发出
invoice.paid - 系统更新订单状态为
paid,订阅状态恢复为active,订阅周期延长到 9 月 1 日 - 支付记录表写入一条
succeeded记录
整个过程通过 Webhook 驱动,本地系统不主动发起重试(Stripe Smart Retries 已经做了最优调度)。
检查清单
在上线订单和订阅系统之前,逐项检查:
- 金额全部以最小货币单位(美分)存储为整数,不使用浮点数
- 订单号有唯一索引,格式包含日期信息便于检索
- 订单表、订阅表、支付记录表之间通过外键关联,引用完整性有保障
- Webhook 处理逻辑实现了幂等性,重复事件不会造成重复操作
- Webhook 签名验证已启用,防止伪造请求
- 订单状态流转使用状态机模式,禁止非法状态跳转
- 订阅的
current_period_end有索引,支持定时任务扫描到期订阅 - 支付失败时记录了完整的
failure_code和failure_message - 退款流程考虑了部分退款和全额退款,订单状态对应更新
- 多币种场景下,
currency字段在订单和支付记录中一致 - Stripe 本地数据库与 Stripe Dashboard 的状态可通过脚本对账
- 试用期到期前有通知机制(邮件或应用内通知)
- 数据备份策略覆盖所有交易表,支持按时间点恢复
参考资料
- A SaaS Subscription Data Model - Redgate — 完整的 SaaS 订阅数据模型,包含用户、计划、订阅、发票四张核心表的设计思路。
- Database Design Patterns for SaaS Applications - ER Flow — 提出 Plans、Subscriptions、Invoices、Payments 四表架构,强调金额以整数存储。
- Design a subscriptions integration - Stripe Documentation — Stripe 官方订阅集成设计指南,覆盖定价模型、计费时机和 Checkout 方案选择。
- Build a subscriptions integration - Stripe Documentation — Stripe 官方订阅集成实现指南,包含 Checkout 和 Elements 两种接入方式的代码示例。
- The Subscription object - Stripe API Reference — Stripe Subscription 对象的完整字段说明,是设计本地订阅表时的核心参考。
- How to store subscriptions? Analysis of PostgreSQL, Redis, and DynamoDB - Medium — 对比三种数据库存储订阅数据的优劣,对技术选型有参考价值。
- 订单系统的表设计探讨 - 掘金 — 从用户信息、商品快照、支付信息、价格信息、售后信息等维度拆解订单系统设计。
- How to Build a Payments System That Scales to Infinity - CockroachDB — 支付系统架构设计,讨论分布式场景下的数据一致性和扩展性。