Files
junhong_cmp_fiber/openspec/changes/refactor-order-payment-module/design.md
huang d26010b29d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 49s
重构用的提案,现在不敢跑
2026-04-14 17:38:36 +08:00

8.4 KiB
Raw Blame History

Context

当前 tb_asset_recharge_record 混合了两个职责:业务订单(用户关心:状态、金额、套餐)和支付凭证(系统关心:交易号、支付渠道、回调)。强充流程创建的是充值记录而非订单,导致用户在 /api/c/v1/orders 看不到强充产生的记录。幂等键命中时直接报错而非引导用户恢复支付,造成"列表为空 + 无法重新下单"的死锁体验。

现有数据流

强充发起  → 写 tb_asset_recharge_record (单号前缀 CRCH)
微信回调  → 更新 tb_asset_recharge_record + 增加钱包余额
auto_purchase → 读 tb_asset_recharge_record.linked_package_ids → 创建 tb_order (套餐) + 扣款

目标数据流

强充发起  → 写 tb_recharge_order + tb_payment (CRCH 前缀保持兼容)
微信回调  → 更新 tb_payment + 更新 tb_recharge_order + 增加钱包余额
auto_purchase → 读 tb_recharge_order.linked_package_ids → 创建 tb_order (套餐) + 创建 tb_payment (钱包) + 扣款

Goals / Non-Goals

Goals:

  • 彻底替换 tb_asset_recharge_record,用 tb_recharge_order + tb_payment 完整承接其所有功能
  • 充值订单通过独立接口 /api/c/v1/recharge-orders 展示;套餐订单接口 /api/c/v1/orders 保持不变
  • 幂等键命中时返回已有待支付充值订单,引导前端拉起支付而非报错
  • 普通钱包充值(非强充)同样写 tb_recharge_order + tb_payment
  • 全部功能验证通过后,作为最后一步 DROP tb_asset_recharge_record

Non-Goals:

  • 不合并 tb_ordertb_recharge_order(各自独立,不引入多态 order_type
  • 不做 UNION 查询(充值订单和套餐订单各有独立入口,不混合展示)
  • 不引入父子订单关联关系
  • 不考虑回退(一次性完整替换,无过渡期)
  • 不迁移历史数据历史充值记录业务已完成DROP 旧表即可)

Decisions

决策 1新建 tb_recharge_order 而非扩展 tb_order

选择:独立的 tb_recharge_order 表,不将 wallet_recharge 作为 order_type 注入 tb_order

原因:充值订单与套餐订单领域边界清晰——充值订单无佣金、无卖家、无套餐明细、有自动购包状态;合并进 tb_order 会使该表充满特定类型才有意义的 NULL 列,且所有查询都需要加 order_type 判断。独立表让每张表的字段 100% 有效,业务逻辑无需条件分支。

放弃的替代方案:多态 order_type 方案——混合 NULL 列问题和业务逻辑污染问题无法解决。

决策 2充值订单和套餐订单各自独立接口

选择/api/c/v1/orders 保持不变,仅返回套餐购买订单(tb_order)。新增 /api/c/v1/recharge-orders 独立返回充值订单(tb_recharge_order)。

原因:两类订单业务语义不同——充值订单有 auto_purchase_status套餐订单有佣金状态强行混合展示需要 UNION 和统一 DTO引入不必要的复杂度和长期维护负担。前端各看各的接口职责清晰DTO 字段 100% 有意义。

决策 3tb_payment 统一承接所有支付凭证

选择:新建 tb_payment 表,order_id + order_typepackage_order / recharge_order)多态关联到对应业务表。

原因:支付是基础设施,与业务订单解耦后,未来新增支付方式(富途、积分等)只需修改支付层,不触碰业务订单表。支付回调路由从"按单号前缀分发到不同 Service"简化为"查 tb_payment → 关联业务订单"。

字段设计

CREATE TABLE tb_payment (
    id                   BIGSERIAL PRIMARY KEY,
    payment_no           VARCHAR(40)  NOT NULL UNIQUE,    -- 保持 CRCH/PAY 前缀兼容
    order_id             BIGINT       NOT NULL,
    order_type           VARCHAR(30)  NOT NULL,           -- 'package_order' | 'recharge_order'
    payment_method       VARCHAR(20)  NOT NULL,           -- wallet | wechat | alipay | offline
    amount               BIGINT       NOT NULL,           -- 分
    status               SMALLINT     NOT NULL DEFAULT 1, -- 1待支付 2已支付 3已失败 4已退款
    third_party_trade_no VARCHAR(100),
    payment_config_id    BIGINT,
    payment_voucher_key  VARCHAR(500),
    paid_at              TIMESTAMPTZ,
    created_at           TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at           TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    deleted_at           TIMESTAMPTZ
);
CREATE INDEX idx_payment_order    ON tb_payment(order_id, order_type);
CREATE INDEX idx_payment_no       ON tb_payment(payment_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_payment_status   ON tb_payment(status) WHERE deleted_at IS NULL;

决策 4tb_recharge_order 字段设计

承接 tb_asset_recharge_record 的业务字段 + 增加与资产的直接关联:

CREATE TABLE tb_recharge_order (
    id                   BIGSERIAL PRIMARY KEY,
    recharge_order_no    VARCHAR(40)  NOT NULL UNIQUE,   -- 沿用 CRCH 前缀
    user_id              BIGINT       NOT NULL,           -- 发起用户(个人客户 ID
    asset_wallet_id      BIGINT       NOT NULL,
    resource_type        VARCHAR(20)  NOT NULL,           -- iot_card | device
    resource_id          BIGINT       NOT NULL,
    iot_card_id          BIGINT,                          -- 单卡充值时有值(用于 ListOrders 查询)
    device_id            BIGINT,                          -- 设备充值时有值
    amount               BIGINT       NOT NULL,           -- 充值金额(分)
    status               SMALLINT     NOT NULL DEFAULT 1, -- 1待支付 2已支付 3已关闭 4已退款
    paid_at              TIMESTAMPTZ,
    shop_id_tag          BIGINT       NOT NULL DEFAULT 0,
    enterprise_id_tag    BIGINT,
    operator_type        VARCHAR(30)  NOT NULL,           -- personal_customer | admin_user
    generation           INT          NOT NULL DEFAULT 1,
    -- 强充专有字段(普通充值为 NULL
    linked_package_ids   JSONB,
    linked_order_type    VARCHAR(20),
    linked_carrier_type  VARCHAR(20),
    linked_carrier_id    BIGINT,
    auto_purchase_status VARCHAR(20),                     -- pending | success | failed
    -- 累计充值(用于强充阈值判断)
    accumulated_recharge_series BIGINT NOT NULL DEFAULT 0,
    created_at           TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at           TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    deleted_at           TIMESTAMPTZ
);
CREATE INDEX idx_recharge_order_card      ON tb_recharge_order(iot_card_id, generation) WHERE deleted_at IS NULL;
CREATE INDEX idx_recharge_order_device    ON tb_recharge_order(device_id, generation) WHERE deleted_at IS NULL;
CREATE INDEX idx_recharge_order_user      ON tb_recharge_order(user_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_recharge_order_no        ON tb_recharge_order(recharge_order_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_recharge_order_status    ON tb_recharge_order(status) WHERE deleted_at IS NULL;

决策 5旧表在所有验证通过后作为最后一步 DROP

选择:迁移过程中保留 tb_asset_recharge_record(只读,不再写入);全部功能验证通过后,最后一个任务执行 DROP TABLE tb_asset_recharge_record

原因:保留旧表到最后是唯一的安全网——若验证发现遗漏的调用路径,还能对照旧表数据排查。一旦验证完整通过,立即 DROP不拖延不留历史包袱。不做重命名归档不设 3-6 个月观察期。

决策 6幂等键修改

现有问题SetNX(redisKey, "processing") 命中时直接返回 "订单正在创建中",不区分"真正在处理"和"已创建待支付"。

修改方案!claimedGET 当前值:

  • 值为 "processing" → 并发请求,返回原有报错
  • 值为 recharge_order_no → 查 tb_recharge_order,若 status=1(待支付)则返回已有充值订单,前端拉起支付

Risks / Trade-offs

  • [历史充值记录不可见] → 接受,/api/c/v1/recharge-orders 仅展示新表数据,用户在新表建立前的历史充值不展示;历史充值已到账,业务无影响
  • [/api/c/v1/wallet/recharges 数据断层] → 接受,旧接口后端改为查 tb_recharge_order,历史数据不补填

Open Questions

  • auto_purchase_status 触发失败时的重试策略是否调整(当前 Asynq 最多重试 3 次)?(不在本次范围,单独讨论)