订单表与发票表设计:钱和服务的对账凭证

订单和发票,说白了就是SaaS系统里「钱」和「服务」之间的桥梁。我见过不少团队,一开始觉得订单表随便建建就行,结果到了月底对账,数据对不上,财务小姐姐直接杀到工位来。嗯,咱们今天就把这块彻底理清楚。

订单表(Order)的核心设计

订单表记录的是「用户买了什么、花了多少钱、状态如何」。我个人习惯把订单分为两类:一次性订单(比如买了个附加模块)和订阅订单(比如按月付费的套餐)。

先看核心字段:

CREATE TABLE `order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID',
  `order_no` varchar(64) NOT NULL COMMENT '订单号,全局唯一',
  `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
  `user_id` bigint(20) NOT NULL COMMENT '下单用户ID',
  `plan_id` bigint(20) DEFAULT NULL COMMENT '订阅计划ID',
  `order_type` tinyint(4) NOT NULL COMMENT '订单类型:1-新购 2-续费 3-升级 4-降级 5-退款',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待支付 1-已支付 2-已取消 3-已退款 4-已完成',
  `total_amount` decimal(12,2) NOT NULL COMMENT '订单总金额,单位元',
  `discount_amount` decimal(12,2) DEFAULT '0.00' COMMENT '折扣金额',
  `pay_amount` decimal(12,2) NOT NULL COMMENT '实付金额',
  `currency` varchar(3) NOT NULL DEFAULT 'CNY' COMMENT '币种',
  `payment_method` varchar(32) DEFAULT NULL COMMENT '支付方式:wechat/alipay/stripe',
  `payment_time` datetime DEFAULT NULL COMMENT '支付时间',
  `period_start` datetime DEFAULT NULL COMMENT '订阅周期开始时间',
  `period_end` datetime DEFAULT NULL COMMENT '订阅周期结束时间',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_tenant_id` (`tenant_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这里有个细节:order_no 我建议用「业务含义+时间戳+随机数」的方式生成。比如 ORD20250315001,这样出了问题,光看订单号就能知道大概时间。我在项目中遇到过,直接用自增ID当订单号,结果被竞争对手猜出了日订单量,挺尴尬的。

关键点:订单状态机一定要设计严谨。比如「已退款」的订单不能再变成「已支付」。我习惯用一张状态转换表来约束,代码里用枚举+校验逻辑双重保障。

发票表(Invoice)设计:财务合规的底线

发票这事,国内国外差别挺大。国内讲究「发票即凭证」,国外更看重「Invoice」作为付款依据。但不管怎样,核心逻辑是一样的:一笔订单可以对应多张发票,一张发票只能属于一笔订单

发票表设计:

CREATE TABLE `invoice` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `invoice_no` varchar(64) NOT NULL COMMENT '发票号',
  `order_id` bigint(20) NOT NULL COMMENT '关联订单ID',
  `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
  `invoice_type` tinyint(4) NOT NULL COMMENT '发票类型:1-普通发票 2-增值税专用发票',
  `title` varchar(200) NOT NULL COMMENT '发票抬头',
  `tax_no` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
  `amount` decimal(12,2) NOT NULL COMMENT '发票金额',
  `tax_rate` decimal(5,2) DEFAULT '0.00' COMMENT '税率,如13.00表示13%',
  `tax_amount` decimal(12,2) DEFAULT '0.00' COMMENT '税额',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待开票 1-已开票 2-已作废',
  `invoice_url` varchar(500) DEFAULT NULL COMMENT '电子发票文件地址',
  `issue_time` datetime DEFAULT NULL COMMENT '开票时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_invoice_no` (`invoice_no`),
  KEY `idx_order_id` (`order_id`),
  KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票表';

你想想看,为什么要把发票和订单分开?因为业务场景不同。用户可能一笔订单分多次开票,或者合并多笔订单开一张票。虽然我建议「一笔订单一张票」最省事,但现实往往更复杂。

实战建议:发票号最好和税控系统对接。我曾经遇到发票号重复导致税局预警的情况,后来加了个「发票号+租户ID」的唯一索引,才彻底解决。

计费事件表(Billing Event):一切计费的原始凭证

计费事件,说白了就是「发生了什么需要计费的事」。比如用户升级了套餐、用量超了阈值、或者订阅到期了。这些事件是计费引擎的输入,也是后续对账的依据。

我设计计费事件表时,遵循一个原则:事件不可变,只追加不修改。因为一旦修改历史事件,整个计费链条就乱了。

CREATE TABLE `billing_event` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `event_id` varchar(64) NOT NULL COMMENT '事件唯一ID,全局唯一',
  `tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `event_type` varchar(50) NOT NULL COMMENT '事件类型:subscription_created/upgraded/downgraded/cancelled/usage_exceeded',
  `source` varchar(50) NOT NULL COMMENT '事件来源:system/user/admin/api',
  `payload` json NOT NULL COMMENT '事件载荷,存储具体数据',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '处理状态:0-待处理 1-已处理 2-处理失败',
  `error_message` varchar(500) DEFAULT NULL COMMENT '处理失败原因',
  `processed_time` datetime DEFAULT NULL COMMENT '处理时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_event_id` (`event_id`),
  KEY `idx_tenant_id` (`tenant_id`),
  KEY `idx_event_type` (`event_type`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计费事件表';

这里 payload 字段我用的是 JSON 类型。为什么?因为不同事件携带的数据结构完全不同。比如「升级事件」需要记录新旧 plan_id,「用量超限事件」需要记录当前用量和阈值。用 JSON 最灵活。

注意:事件处理一定要有重试机制。我曾经因为网络抖动,一批事件处理失败,导致用户订阅状态没更新。后来加了死信队列和人工补偿接口,才算稳了。

数据隔离方案:租户的钱不能混

数据隔离,说白了就是「A公司的订单不能让B公司看到」。这在计费系统里尤其重要,因为涉及财务数据,合规要求非常高。

常见的三种方案:

方案 优点 缺点 适用场景
独立数据库 隔离最彻底,安全性最高 成本高,维护复杂 金融、医疗等强合规行业
共享库独立Schema 隔离性较好,成本适中 跨租户查询麻烦 中型SaaS,租户数几百到几千
共享表+租户ID 成本最低,扩展方便 隔离性弱,需代码保障 小型SaaS,租户数少

我个人习惯用共享表+租户ID的方案,但会加一道「租户上下文过滤器」。什么意思呢?就是在所有查询订单、发票、事件的SQL里,自动拼接 AND tenant_id = ?。这样就算程序员忘了写条件,也不会查到别的租户数据。

核心原则:数据隔离不是数据库的事,是架构的事。从API层到Service层到DAO层,每一层都要有租户感知能力。我见过最惨的案例,就是只在DAO层加了租户ID,结果报表查询直接走了另一个连接池,数据全乱了。

嗯,订单、发票、计费事件这三张表,加上数据隔离方案,基本构成了计费系统的数据底座。下一章咱们聊聊计费引擎的核心逻辑——怎么根据事件生成订单和发票。到时候你会发现,今天设计的这些表,每一个字段都用得上。