fix(douyin-mashida): 平台会改写券码 —— 核销改用平台侧券码 + 失败自愈

真实联调踩到的坑(已用真单复现并定位):
`coupons.syncV2` 返回 `10000 成功` **并不代表平台沿用了我们提交的 cert_no**。
实测 3 笔订单里有 2 笔,平台把我们的 DYC 券码换成了它自己生成的券码。
我们却一直拿**自己的**券码去 `coupons.verifyV3`,平台回
`10017 卡券核销失败[isv.business-failed:010021|核销码与订单不匹配]`;
更坑的是这个错误平台要 **~10.5 秒**才返回,正好越过当时的 10 秒超时,
前端只看到 `douyin_mashida_gateway_timeout`,完全看不出真实原因。

修复:
- 新增 `coupons.list` 出网封装;同步成功后按 order_id 回查平台侧 `cert_no`/`cert_id`
  并落库(迁移 095 新增 platform_cert_no / platform_cert_id / platform_status / platform_synced_at)
- 核销时用 `resolveRedeemVerifyCertNo()`:**平台侧券码优先**,取不到才退回我方券码
- 核销失败后自愈 `tryRecoverVerify()`:
  ① 回查平台,若平台侧已核销 → 按对账结果标记本地已核销(修复"平台已核销、我们报失败"的漂移)
  ② 若是券码被平台改过 → 用平台券码**重试一次**
- 回查不到该订单的券 → 标记 sync 失败并告警(暴露"syncV2 成功但平台无券"的幽灵券)
- 默认出网超时 10s → 20s(平台业务错误要 ~10.5s 才返回,10s 会把业务错误伪装成超时)

实测验证:对一笔「平台侧已核销、本地仍是 synced」的订单调确认兑换,
10.7s 后返回 `status=verified`,platform_cert_no 成功回填为平台券码、
platform_status=3,并打出「对账发现平台侧已核销,按平台状态标记本地已核销」告警。
This commit is contained in:
yml2213
2026-10-09 14:08:55 +08:00
parent d12dce7575
commit de2fb3e008
6 changed files with 397 additions and 5 deletions
@@ -29,6 +29,13 @@ export type RedeemCertificateRow = {
status: RedeemCertificateStatus
sync_status: RedeemCertificateSyncStatus
sync_error: string
/** 平台侧实际券码(syncV2 后由 coupons.list 取回;平台可能不用我们提交的 cert_no) */
platform_cert_no: string
/** 平台侧卡券ID */
platform_cert_id: string
/** 平台侧券状态快照:1未激活/2未使用/3已使用/4已废弃/5已过期/6已完成 */
platform_status: number | null
platform_synced_at: string | null
grant_time: string | null
synced_at: string | null
verified_at: string | null
@@ -37,6 +44,17 @@ export type RedeemCertificateRow = {
updated_at: string
}
/**
* 核销时该用哪个券码:**平台侧券码优先**,取不到才退回我方券码。
* 抽成纯函数便于单测(这是真实踩坑点,不能只用 cert_no)。
*/
export function resolveRedeemVerifyCertNo(row: {
cert_no: string
platform_cert_no?: string | null | undefined
}): string {
return String(row.platform_cert_no || '').trim() || String(row.cert_no || '').trim()
}
export function generateRedeemCertificateCode(
randomBytes: (size: number) => Uint8Array = crypto.randomBytes,
): string {
@@ -175,6 +193,40 @@ export async function markRedeemCertificateSyncFailed(input: {
return result.rows[0] || null
}
/**
* 写回平台侧券码。
*
* ⚠️ 真实联调发现:`coupons.syncV2` 不保证平台沿用我们的 `cert_no`(3 笔里 2 笔被改名),
* 所以同步成功后必须用 `coupons.list` 取回平台侧真实券码,核销时优先用它,
* 否则会拿到 `10017 核销码与订单不匹配`(且该错误要 ~10.5s 才返回,会伪装成超时)。
*/
export async function markRedeemCertificatePlatformCert(input: {
id: number | string
platformCertNo: string
platformCertId?: string | undefined
platformStatus?: number | null | undefined
}): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET platform_cert_no = $2,
platform_cert_id = COALESCE(NULLIF($3, ''), platform_cert_id),
platform_status = $4,
platform_synced_at = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[
Number(input.id),
String(input.platformCertNo || '').trim(),
String(input.platformCertId || '').trim(),
input.platformStatus === undefined ? null : input.platformStatus,
],
)
return result.rows[0] || null
}
/** 核销上报成功:`verified`(已核销的重复调用不再改状态,保持幂等)。 */
export async function markRedeemCertificateVerified(
id: number | string,