是否应为 single attribute sticker_prize 创建独立表?
结论
可以单独建表保存当前价格,但不建议只设计一个没有主键、也不保留历史记录的 sticker_info(sticker_prize) 表。
比较合适的设计是:
- 单独保存管理员设置的当前售价或价格版本。
- 创建订单时,将当时的单价复制到订单明细,作为价格快照。
- 调价只影响之后创建的订单,不修改历史订单的单价。
- 金额字段使用
DECIMAL。字段名应写成price,不要使用含义为“奖品”的prize。
表中只有一个业务字段并不妨碍单独建表。是否需要建表,取决于数据的生命周期、权限控制和审计要求,而不是字段数量。
推荐的数据结构
即使系统目前只销售 sticker,也可以将商品、价格和订单明细分开:
User
└── Order
├── OrderItem ── Product ── ProductPrice
├── Payment
└── Sticker
以 PostgreSQL 为例:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL
);
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE product_prices (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id),
unit_price DECIMAL(12, 2) NOT NULL CHECK (unit_price >= 0),
currency CHAR(3) NOT NULL,
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ,
changed_by BIGINT NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
shipping_address TEXT NOT NULL,
status VARCHAR(30) NOT NULL,
total_amount DECIMAL(12, 2) NOT NULL CHECK (total_amount >= 0),
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
price_id BIGINT REFERENCES product_prices(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12, 2) NOT NULL CHECK (unit_price >= 0),
line_total DECIMAL(12, 2) NOT NULL CHECK (line_total >= 0)
);
CREATE TABLE payments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
amount DECIMAL(12, 2) NOT NULL CHECK (amount >= 0),
currency CHAR(3) NOT NULL,
status VARCHAR(30) NOT NULL,
transaction_ref VARCHAR(100),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE stickers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_item_id BIGINT NOT NULL REFERENCES order_items(id),
name VARCHAR(100),
content TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
product_prices 记录价格的变化过程,order_items.unit_price 则记录下单时实际采用的价格。两个字段看起来重复,但用途不同:
product_prices.unit_price是商品报价。order_items.unit_price是订单确认后的成交价格。price_id用来追踪订单采用的价格版本。- 即使某条价格记录以后被停用,历史订单的金额也不会改变。
创建订单时保存价格快照
假设 sticker 当前售价为 $10.00,用户购买 3 个。创建订单时应写入:
quantity = 3
unit_price = 10.00
line_total = 30.00
如果管理员之后将价格改为 $12.00,已有订单仍保留 $10.00 的单价,只有新订单使用 $12.00。
可以通过下面的查询获取指定时间有效的价格:
SELECT id, unit_price, currency
FROM product_prices
WHERE product_id = :product_id
AND valid_from <= :order_time
AND (valid_to IS NULL OR valid_to > :order_time)
ORDER BY valid_from DESC
LIMIT 1;
读取价格、创建订单和写入订单明细应放在同一个数据库事务中:
BEGIN;
SELECT id, unit_price, currency
FROM product_prices
WHERE product_id = :product_id
AND valid_from <= CURRENT_TIMESTAMP
AND (valid_to IS NULL OR valid_to > CURRENT_TIMESTAMP)
ORDER BY valid_from DESC
LIMIT 1;
INSERT INTO orders (
user_id,
shipping_address,
status,
total_amount,
currency
) VALUES (
:user_id,
:shipping_address,
'pending_payment',
:unit_price * :quantity,
:currency
)
RETURNING id;
INSERT INTO order_items (
order_id,
product_id,
price_id,
quantity,
unit_price,
line_total
) VALUES (
:order_id,
:product_id,
:price_id,
:quantity,
:unit_price,
:unit_price * :quantity
);
COMMIT;
实际应用还要处理并发调价,防止价格查询完成后、订单写入前发生价格切换。常用做法是锁定对应的商品或当前价格记录,同时将“结束旧价格、插入新价格”的操作放进同一个事务。
管理员调价时不要覆盖历史价格
管理员修改价格时,可以先关闭旧价格的有效期,再插入一条新记录:
BEGIN;
UPDATE product_prices
SET valid_to = CURRENT_TIMESTAMP
WHERE product_id = :product_id
AND valid_to IS NULL;
INSERT INTO product_prices (
product_id,
unit_price,
currency,
valid_from,
changed_by
) VALUES (
:product_id,
12.00,
'USD',
CURRENT_TIMESTAMP,
:admin_user_id
);
COMMIT;
采用追加价格版本的方式后,可以查清:
- 某个订单为什么使用这一价格。
- 价格在什么时候发生了调整。
- 哪位管理员修改了价格。
- 某个时间点生效的价格是多少。
还需要通过数据库约束或业务逻辑,确保同一商品的价格有效期不会重叠。
如果系统确定永远只有一种 sticker
如果业务很简单,也不需要保留调价历史,可以使用单行配置表:
CREATE TABLE sticker_settings (
id SMALLINT PRIMARY KEY CHECK (id = 1),
unit_price DECIMAL(12, 2) NOT NULL CHECK (unit_price >= 0),
currency CHAR(3) NOT NULL,
updated_by BIGINT NOT NULL REFERENCES users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO sticker_settings (
id,
unit_price,
currency,
updated_by
) VALUES (
1,
10.00,
'USD',
:admin_user_id
);
管理员调价:
UPDATE sticker_settings
SET unit_price = 12.00,
updated_by = :admin_user_id,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1;
即便使用这种设计,订单仍须保存 unit_price。展示历史订单时不能只读取 sticker_settings.unit_price,否则每次调价后,旧订单看起来也会使用新价格。
单行表适合业务规模较小,而且明确不需要价格历史的情况。如果以后需要审计、定时生效、多币种、促销价或多种 sticker,价格版本表更合适。
还需要注意的设计细节
不要使用浮点数保存金额
不要用 FLOAT 或 DOUBLE 保存价格,可以使用:
DECIMAL(12, 2)
另一种方式是将金额保存为最小货币单位,例如用整数 1000 表示 $10.00。无论选择哪种方式,都应同时保存 currency。
Payment 不应决定订单价格
Payment.amount 表示实际支付金额,Order 或 OrderItem 记录应付金额。这些数据需要分别保存,因为后续可能出现:
- 支付失败或重复支付
- 分次支付
- 优惠和退款
- 支付手续费
- 实付金额与订单金额不一致
生成 sticker 时,应检查支付状态和累计有效支付金额,不能因为存在一条 Payment 记录就认定订单已经付款。
地址也应保存快照
订单需要保留下单时的收货地址。用户以后修改个人地址,不应改变历史订单的配送信息。它与价格快照采用的是同一原则。
管理员权限不能只依赖表结构
“只有管理员可以调价”通常需要通过应用层的身份认证和权限控制来实现。数据库层还可以使用独立的数据库角色、存储过程或表权限限制写操作,同时记录 changed_by 和修改时间。
系统规模很小时,可以使用 sticker_settings 单行表。只要需要追踪价格变化,就应优先使用 product_prices 价格版本表。无论采用哪种方案,订单都必须保存下单时的单价快照。
备注:内容仅供参考。
