在线商店 Class diagram 的数据库表关联设计问题
结论
问题不在于“表太分散”,而在于订单、商品、收款和商家累计销售额被塞进了同一条数据传递链。
更合适的设计是:
Product直接关联商品所属的商家或 needy resident。Order表示一笔订单,OrderItem保存订单中的商品明细。Payment关联订单,不接收某个ordered product的价格。- 商家销售额优先从已支付的订单明细中汇总,避免在多个表之间逐级复制和更新。
- 下单时将商品单价作为快照保存在
OrderItem中,防止商品改价影响历史订单。
数据库中的信息不必从 Product “传递”到 OrderItem,再“传递”到 Payment。只要外键设计正确,查询时通过 JOIN 就能找到商品、商家、订单和付款之间的关系。
推荐的数据关系
主要实体可以整理为以下关系:
Resident 1 ─── N Product
Customer 1 ─── N Order
Order 1 ─── N OrderItem
Product 1 ─── N OrderItem
Order 1 ─── N Payment
如果一笔订单可以包含多个商家的商品,销售额可以沿着下面的关联链计算:
Resident -> Product -> OrderItem -> Order -> Payment
各字段的职责如下:
Product.resident_id表示商品属于哪个 needy resident。Order.customer_id表示订单由哪个 customer 创建。OrderItem.order_id和OrderItem.product_id表示订单购买了哪些商品。Payment.order_id表示付款属于哪笔订单。
如果一笔订单只能购买同一个商家的商品,也可以在 Order 中增加 resident_id。这是业务约束,不应单纯为了方便查询而重复保存。
建议的表结构
下面是一份简化的关系型数据库示例,具体数据类型需要根据所用数据库调整。
CREATE TABLE resident (
resident_id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE customer (
customer_id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE product (
product_id BIGINT PRIMARY KEY,
resident_id BIGINT NOT NULL,
name VARCHAR(200) NOT NULL,
current_price DECIMAL(12, 2) NOT NULL,
stock_quantity INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
CONSTRAINT fk_product_resident
FOREIGN KEY (resident_id)
REFERENCES resident(resident_id),
CONSTRAINT chk_product_price
CHECK (current_price >= 0),
CONSTRAINT chk_product_stock
CHECK (stock_quantity >= 0)
);
CREATE TABLE customer_order (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
order_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
payment_status VARCHAR(20) NOT NULL DEFAULT 'UNPAID',
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id)
REFERENCES customer(customer_id),
CONSTRAINT chk_order_total
CHECK (total_amount >= 0)
);
CREATE TABLE order_item (
order_item_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
line_amount DECIMAL(12, 2) NOT NULL,
CONSTRAINT fk_order_item_order
FOREIGN KEY (order_id)
REFERENCES customer_order(order_id),
CONSTRAINT fk_order_item_product
FOREIGN KEY (product_id)
REFERENCES product(product_id),
CONSTRAINT chk_order_item_quantity
CHECK (quantity > 0),
CONSTRAINT chk_order_item_price
CHECK (unit_price >= 0),
CONSTRAINT chk_order_item_amount
CHECK (line_amount >= 0)
);
CREATE TABLE payment (
payment_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
payment_status VARCHAR(20) NOT NULL,
payment_method VARCHAR(30),
transaction_reference VARCHAR(100),
paid_at TIMESTAMP NULL,
CONSTRAINT fk_payment_order
FOREIGN KEY (order_id)
REFERENCES customer_order(order_id),
CONSTRAINT chk_payment_amount
CHECK (amount >= 0)
);
这里使用 customer_order,因为 ORDER 在不少数据库中是关键字。
下单时应该保存哪些信息
创建订单时,从 Product.current_price 读取当前价格,并写入 OrderItem.unit_price。
INSERT INTO order_item (
order_item_id,
order_id,
product_id,
quantity,
unit_price,
line_amount
)
SELECT
:order_item_id,
:order_id,
product_id,
:quantity,
current_price,
current_price * :quantity
FROM product
WHERE product_id = :product_id
AND status = 'ACTIVE';
unit_price 是必要的历史快照。例如,商品下单时售价为 100,商家之后将价格改成 120,历史订单仍应显示 100,不能重新读取 Product.current_price。
如果业务需要保留历史商品名称、SKU 或商家名称,也可以在 OrderItem 中保存对应的快照:
product_name_snapshot VARCHAR(200),
sku_snapshot VARCHAR(100)
不必把所有商家资料都复制到订单明细中。只有可能发生变化,同时又需要保留历史状态的字段,才适合保存快照。
Payment 不应保存商品价格
Payment 记录实际支付行为,包括:
- 应付或实付金额;
- 支付渠道;
- 第三方交易号;
- 支付状态;
- 支付时间;
- 退款信息。
商品售价属于 OrderItem,订单总额属于 Order。即使当前业务是一件商品对应一次付款,也不建议让 Payment 直接关联 OrderItem。否则以后增加购物车、多商品订单、部分退款或多次支付时,扩展会很困难。
如果一笔订单只能支付一次,可以为 Payment.order_id 添加唯一约束:
ALTER TABLE payment
ADD CONSTRAINT uq_payment_order UNIQUE (order_id);
如果需要支持分次付款、支付失败后的重试,或者组合使用多种支付方式,就不要添加这个约束。一笔订单可以对应多条支付记录。
支付状态和订单状态要分开
“已支付”不能完整描述订单状态,至少应该把状态分成两组。
支付状态:
UNPAID
PENDING
PAID
PARTIALLY_REFUNDED
REFUNDED
FAILED
履约状态:
PENDING
CONFIRMED
PROCESSING
SHIPPED
COMPLETED
CANCELLED
例如,一笔订单可以同时处于以下状态:
payment_status = 'PAID'
order_status = 'PROCESSING'
不要用一个 status 字段同时表示付款、发货、完成和取消,否则状态组合很快就会混乱。
商家销售额应该如何计算
通常不建议在每次付款后直接修改 resident.sales_amount。支付回调重复、退款、订单取消或程序异常,都可能导致累计字段出现偏差。
更稳妥的做法是实时汇总已支付的订单:
SELECT
p.resident_id,
SUM(oi.line_amount) AS gross_sales
FROM order_item AS oi
JOIN product AS p
ON p.product_id = oi.product_id
JOIN customer_order AS o
ON o.order_id = oi.order_id
WHERE o.payment_status = 'PAID'
GROUP BY p.resident_id;
这个查询有一个前提:订单没有发生部分退款,并且支付状态能准确表示订单是否已经全额支付。
如果还要处理退款、平台手续费、优惠券、运费和商家结算,只汇总 line_amount 就不够了。此时可以增加独立的商家账本或结算明细表:
CREATE TABLE resident_ledger (
ledger_entry_id BIGINT PRIMARY KEY,
resident_id BIGINT NOT NULL,
order_item_id BIGINT,
entry_type VARCHAR(30) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ledger_resident
FOREIGN KEY (resident_id)
REFERENCES resident(resident_id),
CONSTRAINT fk_ledger_order_item
FOREIGN KEY (order_item_id)
REFERENCES order_item(order_item_id)
);
entry_type 可以包括:
SALE
REFUND
COMMISSION
ADJUSTMENT
PAYOUT
销售发生时写入一条 SALE,退款时再写入一条金额为负数的 REFUND,不修改原来的销售记录。这样便于审计,也能减少累计金额失真的风险。
一个订单包含多个商家时的处理
如果平台允许一个购物车中出现多个 needy resident 的商品,可以采用下面两种模式。
模式一:一个订单包含多个商家
OrderItem 通过 Product 找到对应商家。付款仍然关联整笔订单,再根据各项订单明细计算每个商家的收入。
这种设计更符合顾客的购物习惯,但优惠、运费、退款和结算的分摊会更复杂。
模式二:创建父订单和商家子订单
可以增加 seller_order:
Order
├── SellerOrder A
│ ├── OrderItem 1
│ └── OrderItem 2
└── SellerOrder B
└── OrderItem 3
示例结构如下:
CREATE TABLE seller_order (
seller_order_id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
resident_id BIGINT NOT NULL,
subtotal DECIMAL(12, 2) NOT NULL,
fulfillment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
FOREIGN KEY (order_id)
REFERENCES customer_order(order_id),
FOREIGN KEY (resident_id)
REFERENCES resident(resident_id),
UNIQUE (order_id, resident_id)
);
多商家平台通常更适合这种结构,因为每个商家都可以独立接单、发货、取消和结算。
支付成功时的正确流程
支付流程可以按以下顺序处理:
- 创建
Order,将初始支付状态设为UNPAID。 - 根据商品当前价格创建
OrderItem,同时保存价格快照。 - 计算并保存订单总额。
- 创建
Payment,状态设为PENDING。 - 支付渠道返回成功结果后,将
Payment更新为PAID。 - 确认有效支付总额达到订单应付金额后,将订单更新为
PAID。 - 如果使用账本,在同一事务中生成商家的销售账目。
- 后续退款通过退款记录或负数账目冲销。
关键更新应放在数据库事务中。例如:
BEGIN;
UPDATE payment
SET payment_status = 'PAID',
paid_at = CURRENT_TIMESTAMP,
transaction_reference = :transaction_reference
WHERE payment_id = :payment_id
AND payment_status = 'PENDING';
UPDATE customer_order
SET payment_status = 'PAID'
WHERE order_id = :order_id
AND payment_status = 'UNPAID';
INSERT INTO resident_ledger (
ledger_entry_id,
resident_id,
order_item_id,
entry_type,
amount
)
SELECT
:ledger_entry_id,
p.resident_id,
oi.order_item_id,
'SALE',
oi.line_amount
FROM order_item AS oi
JOIN product AS p
ON p.product_id = oi.product_id
WHERE oi.order_item_id = :order_item_id;
COMMIT;
实际实现还要处理幂等性,因为支付平台可能多次发送同一个支付成功通知。可以使用唯一的 transaction_reference,并为账本添加业务唯一约束,防止重复记账。
例如:
ALTER TABLE payment
ADD CONSTRAINT uq_payment_transaction
UNIQUE (transaction_reference);
ALTER TABLE resident_ledger
ADD CONSTRAINT uq_ledger_sale_item
UNIQUE (order_item_id, entry_type);
需要特别注意的问题
- 金额应使用
DECIMAL,不要使用FLOAT或DOUBLE。 - 外键用于表达实体关系,不必为了“传递数据”而重复保存所有字段。
OrderItem必须保存下单时的单价,不能在查询历史订单时使用商品当前价格。- 付款金额应与订单关联,不应由
OrderItem.price直接覆盖。 - 销售额、实收金额和可结算金额是不同的概念,需要分别定义。
- 支付成功回调、退款和记账操作必须支持重复请求,同时避免重复入账。
- 库存扣减、订单创建和价格确认通常需要事务或并发控制。
- 如果商品可以转让给其他商家,最好在
OrderItem或SellerOrder中保存下单时的resident_id,以保留历史归属。 - 删除商品或商家时,不要直接级联删除历史订单。通常应将其停用,或者把相关外键的删除行为设为
RESTRICT。
表之间彼此分开没有问题,这本来就是关系型数据库规范化设计的常见形式。需要调整的是各实体的职责:商品、订单、付款和结算分别管理自己的数据,再用明确的主键和外键建立关联。
备注:内容仅供参考。
