feat(douyin-mashida): 抖音码速达最小接入后端(消息建单/券码同步/兑换核销/退款作废)

- 签名:出网 md5(secret+Σ(名+值)+secret) 与推送 md5(secret+data+pub_time+topic+secret)
  两套独立实现,含文档 golden 向量单测(与 91 大写 / affiliate_dash HMAC 都不通用)
- 进单:trade_TradePaid 推送 → 验签 → webhook_events 幂等 → order.orderDetail
  → upsertOrderFromSource(provider=douyin_mashida / platform=doudian)
- 精度:19 位订单号超出 JS 安全整数,JSON.parse 会把 p_id 抹成 ...000;
  故解析后从原始 JSON 文本用正则抓回精确数字(单测同时证明"不修会错")
- 券码:迁移 094 redeem_certificates((provider,order_id) 唯一做发券幂等)
  + DYC 券码生成 + coupons.syncV2 同步(子单号优先、父单兜底)
- 兑换:POST /api/v1/redeem/lookup|confirm,按 orderNo+IP 限流;
  核销成功后 order.message 通知买家(best-effort,失败只记错误日志)
- 退款/取消:作废未核销券;券已核销时打 WARN 资损告警(已消费不可回滚)
- 后台:GET/POST /api/v1/admin/platform-config/douyin-mashida,
  secret 出参置空 + 掩码,掩码回写不覆盖原值
- 消息路由挂在全局 body parser 之前(app.ts),验签必须用 body 原始字符串
This commit is contained in:
yml2213
2026-10-09 12:00:50 +08:00
parent 893388b37f
commit 72606796b3
21 changed files with 2806 additions and 0 deletions
@@ -0,0 +1,57 @@
-- 094_redeem_certificates.sql —— 兑换券表(抖音码速达最小接入,多平台预留 provider 列)。
--
-- 背景:抖音码速达把抖店订单推给我们履约,我们**自己生成券码**(DYC 前缀)通过
-- `coupons.syncV2` 交给平台展示给买家;买家打开固定兑换链接 `https://<域名>/redeem`
-- 输入订单号兑换,我们再调 `coupons.verifyV3` 上报核销。
--
-- 为什么独立建表而**不复用** kuaishou_industry_vouchers:
-- 那张表是「快手电子凭证」(字段 oid/token/seller_id,核销走快手 OpenAPI),
-- 与本表的「我们自发的兑换券」语义不同,混用会污染既有状态机与发码回调重试逻辑。
--
-- 量级预期:与订单量同阶(一单一券),需要按 order / cert_no 精确检索。
CREATE TABLE IF NOT EXISTS redeem_certificates (
id BIGSERIAL PRIMARY KEY,
-- 平台标识:本期只会有 douyin_mashida;列保留是为了以后接第二个平台时不用改表
provider TEXT NOT NULL,
-- DYC + Crockford Base32(全局唯一)
cert_no TEXT NOT NULL,
-- 回执给平台用的订单号(syncV2 / order.message 的 order_id,子订单号优先、父单兜底)
order_id TEXT NOT NULL,
-- 平台父订单号,便于排查
parent_order_id TEXT NOT NULL DEFAULT '',
-- 本地关联(兑换页按订单号查到本地订单后反查券)
local_order_id BIGINT,
order_item_id BIGINT,
-- 发给平台的固定兑换链接(配置项 certLink,不硬编码)
cert_link TEXT NOT NULL DEFAULT '',
-- issued 已生成 / synced 已同步给平台 / verified 已核销 / invalid 已作废(退款、取消)
status TEXT NOT NULL DEFAULT 'issued'
CHECK (status IN ('issued', 'synced', 'verified', 'invalid')),
sync_status TEXT NOT NULL DEFAULT 'pending'
CHECK (sync_status IN ('pending', 'success', 'failed')),
sync_error TEXT NOT NULL DEFAULT '',
grant_time TIMESTAMPTZ,
synced_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 券码全局唯一:兑换页/核销都按券码反查
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_certificates_cert_no
ON redeem_certificates(cert_no);
-- 同一平台同一订单号只允许一张券:发券幂等(平台重复推消息不会重复发券)
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_certificates_order
ON redeem_certificates(provider, order_id);
-- 兑换页按本地订单反查券(订单号查询命中后取券状态)
CREATE INDEX IF NOT EXISTS idx_redeem_certificates_local_order
ON redeem_certificates(local_order_id);
COMMENT ON TABLE redeem_certificates IS '兑换券(我方生成、经第三方平台展示给买家的券码;抖音码速达最小接入)';
COMMENT ON COLUMN redeem_certificates.order_id IS 'syncV2/order.message 用的订单号(子订单号优先、父单兜底)';
COMMENT ON COLUMN redeem_certificates.status IS 'issued 已生成 / synced 已同步 / verified 已核销 / invalid 已作废';
COMMENT ON COLUMN redeem_certificates.cert_link IS '固定兑换链接(来自配置 certLink,发出去就收不回来,换域名只影响新券)';