From de2fb3e008dccc8ba208b1715521ac34f49177bf Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 14:08:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyin-mashida):=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E6=94=B9=E5=86=99=E5=88=B8=E7=A0=81=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=A0=B8=E9=94=80=E6=94=B9=E7=94=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BE=A7=E5=88=B8=E7=A0=81=20+=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实联调踩到的坑(已用真单复现并定位): `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,并打出「对账发现平台侧已核销,按平台状态标记本地已核销」告警。 --- .../095_redeem_certificates_platform_cert.sql | 28 +++ .../repositories/redeem-certificate-repo.ts | 52 +++++ .../douyin-mashida/gateway-client.ts | 42 ++++ .../douyin-mashida/source-config-service.ts | 6 +- .../services/redeem/certificate-service.ts | 213 +++++++++++++++++- .../services/redeem/redeem-service.test.ts | 61 ++++- 6 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/db/migrations/095_redeem_certificates_platform_cert.sql diff --git a/apps/backend/src/db/migrations/095_redeem_certificates_platform_cert.sql b/apps/backend/src/db/migrations/095_redeem_certificates_platform_cert.sql new file mode 100644 index 00000000..daae2fb4 --- /dev/null +++ b/apps/backend/src/db/migrations/095_redeem_certificates_platform_cert.sql @@ -0,0 +1,28 @@ +-- 095_redeem_certificates_platform_cert.sql —— 记录**平台侧实际券码**。 +-- +-- 背景(真实联调发现):`coupons.syncV2` **不保证平台沿用我们提交的 `cert_no`**。 +-- 实测 3 笔订单里有 2 笔,平台把我们的券码(DYC…)换成了它自己生成的券码; +-- 而我们一直拿**自己的**券码去调 `coupons.verifyV3`,平台返回 +-- `10017 卡券核销失败[isv.business-failed:010021|核销码与订单不匹配]`。 +-- 更坑的是:这个错误平台要 **~10.5 秒**才返回,正好越过我们当时 10 秒的出网超时, +-- 于是前端只看到 `douyin_mashida_gateway_timeout`,完全看不出真实原因。 +-- +-- 处理:`syncV2` 成功后立即用 `coupons.list`(按 order_id 查)取回平台侧的 +-- `cert_no` / `cert_id` 存到本表,核销时**优先用平台侧券码**。 +-- +-- 量级预期:同 094,一单一券;这两列只读不索引(检索都走 cert_no / order_id)。 + +ALTER TABLE redeem_certificates + -- 平台侧实际券码;为空表示"尚未从平台取回",此时退回用我方 cert_no + ADD COLUMN IF NOT EXISTS platform_cert_no TEXT NOT NULL DEFAULT '', + -- 平台侧卡券ID(coupons.list 的 cert_id),用于排查对不上号的情况 + ADD COLUMN IF NOT EXISTS platform_cert_id TEXT NOT NULL DEFAULT '', + -- 平台侧状态快照(coupons.list 的 status:1 未激活 / 2 未使用 / 3 已使用 / 4 已废弃 / 5 已过期 / 6 已完成) + ADD COLUMN IF NOT EXISTS platform_status INTEGER, + -- 最近一次成功从平台取回券码的时间 + ADD COLUMN IF NOT EXISTS platform_synced_at TIMESTAMPTZ; + +COMMENT ON COLUMN redeem_certificates.platform_cert_no IS + '平台侧实际券码(syncV2 后由 coupons.list 取回;平台可能不用我们提交的 cert_no),核销优先用它'; +COMMENT ON COLUMN redeem_certificates.platform_cert_id IS '平台侧卡券ID(coupons.list 的 cert_id),排查用'; +COMMENT ON COLUMN redeem_certificates.platform_status IS '平台侧券状态快照:1未激活/2未使用/3已使用/4已废弃/5已过期/6已完成'; diff --git a/apps/backend/src/repositories/redeem-certificate-repo.ts b/apps/backend/src/repositories/redeem-certificate-repo.ts index 16c4206e..11a79f23 100644 --- a/apps/backend/src/repositories/redeem-certificate-repo.ts +++ b/apps/backend/src/repositories/redeem-certificate-repo.ts @@ -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 { + const result = await query( + ` + 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, diff --git a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts index d7aeedc0..f127dec9 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts @@ -55,6 +55,48 @@ export async function syncDouyinMashidaCoupons( }) } +/** + * 券码查询(`coupons.list`,基础功能)——**同步后取回平台侧真实券码的权威手段**。 + * + * 实测发现 `coupons.syncV2` 不保证平台沿用我们提交的 `cert_no`(3 笔里 2 笔被平台改名), + * 拿我们自己的券码去核销会得到 `10017 核销码与订单不匹配`(且该错误要 ~10.5s 才返回)。 + * 所以同步成功后必须用它按 `order_id` 查回平台的 `cert_no` / `cert_id`。 + */ +export async function fetchDouyinMashidaCoupons( + input: { + orderId?: string | undefined + certNo?: string | undefined + certType?: number | undefined + } & DouyinMashidaCallOptions, +): Promise>> { + const params: Record = { + // 0 = 三方卡券(我们这种自备券码的就是三方卡券);1 会查不到任何券 + cert_type: String(input.certType === undefined ? 0 : input.certType), + } + const orderId = String(input.orderId || '').trim() + const certNo = String(input.certNo || '').trim() + if (orderId) { + params.order_id = orderId + } + if (certNo) { + params.cert_no = certNo + } + + const response = await douyinMashidaGatewayRequest({ + method: 'coupons.list', + params, + config: input.config, + timeoutMs: input.timeoutMs, + }) + + return Array.isArray(response.data) + ? response.data.filter( + (row): row is Record => + Object.prototype.toString.call(row) === '[object Object]', + ) + : [] +} + /** 卡券核销(高级功能):只认券码,所以券码必须能反查本地订单。 */ export async function verifyDouyinMashidaCoupon( input: { certNo: string } & DouyinMashidaCallOptions, diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index 45842b9a..90f5d296 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -110,7 +110,9 @@ export function normalizeDouyinMashidaSourceConfig(rawValue: unknown): DouyinMas messageType: normalizeMessageType(source.messageType), afterVerifyLink: String(source.afterVerifyLink || '').trim(), afterVerifyMessage: String(source.afterVerifyMessage || '').trim(), - timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000), + // ⚠️ 默认 20s 而不是 10s:平台在「券码与订单不匹配」这类业务错误上要 ~10.5s 才返回, + // 10s 会让所有业务错误都伪装成"网关超时",排查时完全看不出真实原因(实测踩过)。 + timeoutMs: normalizePositiveInteger(source.timeoutMs, 20000), handledTopics: normalizeHandledTopics(source.handledTopics), } } @@ -125,7 +127,7 @@ export function createDefaultDouyinMashidaSourceConfig(): DouyinMashidaSourceCon messageType: 2, afterVerifyLink: '', afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE, - timeoutMs: 10000, + timeoutMs: 20000, handledTopics: [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS], } } diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index 3087a12d..381c8a60 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -4,9 +4,11 @@ import { insertRedeemCertificate, invalidateRedeemCertificatesByLocalOrder, listRedeemCertificatesByLocalOrder, + markRedeemCertificatePlatformCert, markRedeemCertificateSynced, markRedeemCertificateSyncFailed, markRedeemCertificateVerified, + resolveRedeemVerifyCertNo, type RedeemCertificateRow, } from '../../repositories/redeem-certificate-repo.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' @@ -16,6 +18,7 @@ import { logIntegration } from '../../utils/logger.js' import { nowIso } from '../../utils/time.js' import { cancelDouyinMashidaCouponVerify, + fetchDouyinMashidaCoupons, sendDouyinMashidaOrderMessage, syncDouyinMashidaCoupons, verifyDouyinMashidaCoupon, @@ -150,7 +153,8 @@ async function syncCertificateToPlatform( orderId: certificate.order_id, }) - return synced || certificate + // syncV2 返回成功 ≠ 平台一定用了我们的券码,必须回查平台侧真实券码 + return (await capturePlatformCert(synced || certificate, config)) || synced || certificate } catch (error) { const message = error instanceof Error ? error.message : '卡券同步失败' await markRedeemCertificateSyncFailed({ id: certificate.id, error: message }) @@ -193,7 +197,18 @@ export async function verifyCertificate(input: { }) } - await verifyDouyinMashidaCoupon({ certNo, config }) + // ⚠️ 必须用**平台侧券码**:syncV2 可能把我们的券码改名,用旧券码会得到 + // `10017 核销码与订单不匹配`(且平台要 ~10.5s 才返回,看起来像超时)。 + const attemptedCertNo = resolveRedeemVerifyCertNo(found) + + try { + await verifyDouyinMashidaCoupon({ certNo: attemptedCertNo, config }) + } catch (error) { + const recovered = await tryRecoverVerify(found, attemptedCertNo, config) + if (!recovered) { + throw error + } + } const verified = await markRedeemCertificateVerified(found.id) @@ -301,6 +316,200 @@ export async function invalidateCertificatesForLocalOrder(input: { return 0 } +/** + * 核销失败后的自愈:回查平台侧券码,判断「其实已核销」或「换正确的券码重试一次」。 + * + * 覆盖两种真实情况: + * 1. 首次调用超时/网络抖动,但平台其实已经核销成功 → 按对账结果标记本地已核销, + * 避免"平台已核销、我们报失败"的状态漂移(用户会重复点击)。 + * 2. 我们存的是旧券码(平台改过名,历史订单没有 platform_cert_no)→ 用平台券码重试一次。 + * + * 返回 true 表示可以继续走「标记已核销」;返回 false/null 表示无法自愈,按原错误抛出。 + */ +async function tryRecoverVerify( + certificate: RedeemCertificateRow, + attemptedCertNo: string, + config: DouyinMashidaSourceConfig, +): Promise { + let rows: Array> + try { + rows = await fetchDouyinMashidaCoupons({ orderId: certificate.order_id, config }) + } catch (error) { + logIntegration( + '[douyin-mashida/certificate]', + '核销失败后对账查询也失败,按原错误返回', + { + certNo: certificate.cert_no, + orderId: certificate.order_id, + error: error instanceof Error ? error.message : String(error), + }, + { level: 'warn' }, + ) + return false + } + + const picked = pickPlatformCouponRow(rows, certificate.cert_no) + if (!picked) { + return false + } + + const platformCertNo = String(picked.cert_no || '').trim() + if (platformCertNo && platformCertNo !== certificate.platform_cert_no) { + await markRedeemCertificatePlatformCert({ + id: certificate.id, + platformCertNo, + platformCertId: String(picked.cert_id || '').trim(), + platformStatus: Number.isFinite(Number(picked.status)) ? Number(picked.status) : null, + }) + } + + if (isPlatformCouponVerified(picked)) { + logIntegration( + '[douyin-mashida/certificate]', + '对账发现平台侧已核销,按平台状态标记本地已核销', + { + certNo: certificate.cert_no, + platformCertNo, + attemptedCertNo, + platformStatus: picked.status, + verifyNum: picked.verify_num, + }, + { level: 'warn' }, + ) + return true + } + + if (platformCertNo && platformCertNo !== attemptedCertNo) { + try { + await verifyDouyinMashidaCoupon({ certNo: platformCertNo, config }) + logIntegration( + '[douyin-mashida/certificate]', + '首次核销用了错误的券码,已改用平台侧券码重试成功', + { certNo: certificate.cert_no, platformCertNo, attemptedCertNo }, + { level: 'warn' }, + ) + return true + } catch (retryError) { + logIntegration( + '[douyin-mashida/certificate]', + '用平台侧券码重试核销仍失败', + { + certNo: certificate.cert_no, + platformCertNo, + error: retryError instanceof Error ? retryError.message : String(retryError), + }, + { level: 'error' }, + ) + return false + } + } + + return false +} + +/** + * 同步成功后回查平台侧真实券码并落库。 + * + * 真实联调发现:`coupons.syncV2` 返回 `10000` 时平台**可能并没有沿用我们提交的 cert_no** + * (3 笔里 2 笔被平台改名)。若此时仍拿我方券码去核销,平台会返回 + * `10017 核销码与订单不匹配`,而且**要 ~10.5 秒才返回**,看起来就像网关超时。 + */ +async function capturePlatformCert( + certificate: RedeemCertificateRow, + config: DouyinMashidaSourceConfig, +): Promise { + let rows: Array> + try { + rows = await fetchDouyinMashidaCoupons({ orderId: certificate.order_id, config }) + } catch (error) { + // 回查失败不改券状态(同步本身是成功的),只留一条告警便于排查 + logIntegration( + '[douyin-mashida/certificate]', + '回查平台侧券码失败(同步状态保持成功)', + { + certNo: certificate.cert_no, + orderId: certificate.order_id, + error: error instanceof Error ? error.message : String(error), + }, + { level: 'warn' }, + ) + return null + } + + const picked = pickPlatformCouponRow(rows, certificate.cert_no) + + if (!picked) { + const message = '平台未返回该订单的卡券(syncV2 报成功但平台侧查不到)' + await markRedeemCertificateSyncFailed({ id: certificate.id, error: message }) + logIntegration( + '[douyin-mashida/certificate]', + message, + { certNo: certificate.cert_no, orderId: certificate.order_id, platformRowCount: rows.length }, + { level: 'warn' }, + ) + return null + } + + const platformCertNo = String(picked.cert_no || '').trim() + const updated = await markRedeemCertificatePlatformCert({ + id: certificate.id, + platformCertNo, + platformCertId: String(picked.cert_id || '').trim(), + platformStatus: Number.isFinite(Number(picked.status)) ? Number(picked.status) : null, + }) + + logIntegration('[douyin-mashida/certificate]', '已取回平台侧券码', { + certNo: certificate.cert_no, + platformCertNo, + renamed: platformCertNo !== certificate.cert_no, + platformStatus: picked.status, + }) + + return updated +} + +/** + * 从 `coupons.list` 结果里挑出属于本次同步的那张券。 + * 优先券码完全一致的;否则只剩一条时直接采用;多条且都不一致时取「未使用」的。 + */ +export function pickPlatformCouponRow( + rows: Array>, + ourCertNo: string, +): Record | null { + const usable = (Array.isArray(rows) ? rows : []).filter( + (row) => Object.prototype.toString.call(row) === '[object Object]', + ) + if (usable.length === 0) { + return null + } + + const exact = usable.find( + (row) => String(row.cert_no || '').trim() === String(ourCertNo || '').trim(), + ) + if (exact) { + return exact + } + if (usable.length === 1) { + return usable[0] || null + } + + // 平台是否已在使用这张券:3 已使用 / verify_num > 0 都算 + return ( + usable.find((row) => Number(row.status) === 2) || + usable.find((row) => !isPlatformCouponVerified(row)) || + usable[0] || + null + ) +} + +/** 平台侧是否已经核销过(status=3 已使用,或 verify_num>0)。 */ +export function isPlatformCouponVerified(row: Record | null | undefined): boolean { + if (!row) { + return false + } + return Number(row.status) === 3 || Number(row.verify_num || 0) > 0 +} + export async function findCertificateOrThrow(certNo: string): Promise { const row = await findRedeemCertificateByCertNo(certNo) diff --git a/apps/backend/src/services/redeem/redeem-service.test.ts b/apps/backend/src/services/redeem/redeem-service.test.ts index 26f7096f..997d7442 100644 --- a/apps/backend/src/services/redeem/redeem-service.test.ts +++ b/apps/backend/src/services/redeem/redeem-service.test.ts @@ -4,9 +4,15 @@ import assert from 'node:assert/strict' import { REDEEM_CERT_CODE_PREFIX, generateRedeemCertificateCode, + resolveRedeemVerifyCertNo, } from '../../repositories/redeem-certificate-repo.js' import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js' -import { buildAfterVerifyMessage, formatGrantTime } from './certificate-service.js' +import { + buildAfterVerifyMessage, + formatGrantTime, + isPlatformCouponVerified, + pickPlatformCouponRow, +} from './certificate-service.js' import { maskCertNo, normalizeOrderNo } from './redeem-service.js' test('DYC 券码:前缀 + 16 位 Crockford Base32(去易混字符)', () => { @@ -67,3 +73,56 @@ test('兑换后通知买家的文案:默认文案 / 带链接 / 自定义文 '充值已完成,请点击使用:https://x.com/c', ) }) + +test('核销券码必须优先用平台侧券码(真实踩坑:平台会改名)', () => { + // 平台改名过 → 用平台券码 + assert.equal( + resolveRedeemVerifyCertNo({ + cert_no: 'DYC05EJH7G66VG0EVFN', + platform_cert_no: 'S683sWRJXaY74DZkrT', + }), + 'S683sWRJXaY74DZkrT', + ) + // 还没回查到平台券码(历史数据/回查失败)→ 退回我方券码 + assert.equal(resolveRedeemVerifyCertNo({ cert_no: 'DYCXXX', platform_cert_no: '' }), 'DYCXXX') + assert.equal(resolveRedeemVerifyCertNo({ cert_no: 'DYCXXX' }), 'DYCXXX') +}) + +test('从 coupons.list 结果里挑券:券码一致优先,其次唯一一条,都改名则取未使用的', () => { + assert.equal(pickPlatformCouponRow([], 'DYC1'), null) + + // 券码完全一致 + assert.equal( + pickPlatformCouponRow( + [ + { cert_no: 'OTHER', status: 2 }, + { cert_no: 'DYC1', status: 2 }, + ], + 'DYC1', + )?.cert_no, + 'DYC1', + ) + // 平台改了名但只有一条 → 采用(这正是真实场景) + assert.equal( + pickPlatformCouponRow([{ cert_no: 'ExsweiKSGejRcM3Z06', status: 2 }], 'DYC2')?.cert_no, + 'ExsweiKSGejRcM3Z06', + ) + // 多条都改名 → 取「未使用」那条 + assert.equal( + pickPlatformCouponRow( + [ + { cert_no: 'A', status: 3 }, + { cert_no: 'B', status: 2 }, + ], + 'DYC3', + )?.cert_no, + 'B', + ) +}) + +test('平台侧是否已核销:status=3 或 verify_num>0', () => { + assert.equal(isPlatformCouponVerified({ status: 3, verify_num: 1 }), true) + assert.equal(isPlatformCouponVerified({ status: 2, verify_num: 1 }), true) + assert.equal(isPlatformCouponVerified({ status: 2, verify_num: 0 }), false) + assert.equal(isPlatformCouponVerified(null), false) +})