PAYATHON 2026

是否应为 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 价格版本表。无论采用哪种方案,订单都必须保存下单时的单价快照。

备注:内容仅供参考。