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:
@@ -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已完成';
|
||||||
@@ -29,6 +29,13 @@ export type RedeemCertificateRow = {
|
|||||||
status: RedeemCertificateStatus
|
status: RedeemCertificateStatus
|
||||||
sync_status: RedeemCertificateSyncStatus
|
sync_status: RedeemCertificateSyncStatus
|
||||||
sync_error: string
|
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
|
grant_time: string | null
|
||||||
synced_at: string | null
|
synced_at: string | null
|
||||||
verified_at: string | null
|
verified_at: string | null
|
||||||
@@ -37,6 +44,17 @@ export type RedeemCertificateRow = {
|
|||||||
updated_at: string
|
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(
|
export function generateRedeemCertificateCode(
|
||||||
randomBytes: (size: number) => Uint8Array = crypto.randomBytes,
|
randomBytes: (size: number) => Uint8Array = crypto.randomBytes,
|
||||||
): string {
|
): string {
|
||||||
@@ -175,6 +193,40 @@ export async function markRedeemCertificateSyncFailed(input: {
|
|||||||
return result.rows[0] || null
|
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`(已核销的重复调用不再改状态,保持幂等)。 */
|
/** 核销上报成功:`verified`(已核销的重复调用不再改状态,保持幂等)。 */
|
||||||
export async function markRedeemCertificateVerified(
|
export async function markRedeemCertificateVerified(
|
||||||
id: number | string,
|
id: number | string,
|
||||||
|
|||||||
@@ -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<Array<Record<string, unknown>>> {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
// 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<string, unknown> =>
|
||||||
|
Object.prototype.toString.call(row) === '[object Object]',
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
/** 卡券核销(高级功能):只认券码,所以券码必须能反查本地订单。 */
|
/** 卡券核销(高级功能):只认券码,所以券码必须能反查本地订单。 */
|
||||||
export async function verifyDouyinMashidaCoupon(
|
export async function verifyDouyinMashidaCoupon(
|
||||||
input: { certNo: string } & DouyinMashidaCallOptions,
|
input: { certNo: string } & DouyinMashidaCallOptions,
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ export function normalizeDouyinMashidaSourceConfig(rawValue: unknown): DouyinMas
|
|||||||
messageType: normalizeMessageType(source.messageType),
|
messageType: normalizeMessageType(source.messageType),
|
||||||
afterVerifyLink: String(source.afterVerifyLink || '').trim(),
|
afterVerifyLink: String(source.afterVerifyLink || '').trim(),
|
||||||
afterVerifyMessage: String(source.afterVerifyMessage || '').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),
|
handledTopics: normalizeHandledTopics(source.handledTopics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +127,7 @@ export function createDefaultDouyinMashidaSourceConfig(): DouyinMashidaSourceCon
|
|||||||
messageType: 2,
|
messageType: 2,
|
||||||
afterVerifyLink: '',
|
afterVerifyLink: '',
|
||||||
afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE,
|
afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE,
|
||||||
timeoutMs: 10000,
|
timeoutMs: 20000,
|
||||||
handledTopics: [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS],
|
handledTopics: [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
insertRedeemCertificate,
|
insertRedeemCertificate,
|
||||||
invalidateRedeemCertificatesByLocalOrder,
|
invalidateRedeemCertificatesByLocalOrder,
|
||||||
listRedeemCertificatesByLocalOrder,
|
listRedeemCertificatesByLocalOrder,
|
||||||
|
markRedeemCertificatePlatformCert,
|
||||||
markRedeemCertificateSynced,
|
markRedeemCertificateSynced,
|
||||||
markRedeemCertificateSyncFailed,
|
markRedeemCertificateSyncFailed,
|
||||||
markRedeemCertificateVerified,
|
markRedeemCertificateVerified,
|
||||||
|
resolveRedeemVerifyCertNo,
|
||||||
type RedeemCertificateRow,
|
type RedeemCertificateRow,
|
||||||
} from '../../repositories/redeem-certificate-repo.js'
|
} from '../../repositories/redeem-certificate-repo.js'
|
||||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.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 { nowIso } from '../../utils/time.js'
|
||||||
import {
|
import {
|
||||||
cancelDouyinMashidaCouponVerify,
|
cancelDouyinMashidaCouponVerify,
|
||||||
|
fetchDouyinMashidaCoupons,
|
||||||
sendDouyinMashidaOrderMessage,
|
sendDouyinMashidaOrderMessage,
|
||||||
syncDouyinMashidaCoupons,
|
syncDouyinMashidaCoupons,
|
||||||
verifyDouyinMashidaCoupon,
|
verifyDouyinMashidaCoupon,
|
||||||
@@ -150,7 +153,8 @@ async function syncCertificateToPlatform(
|
|||||||
orderId: certificate.order_id,
|
orderId: certificate.order_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
return synced || certificate
|
// syncV2 返回成功 ≠ 平台一定用了我们的券码,必须回查平台侧真实券码
|
||||||
|
return (await capturePlatformCert(synced || certificate, config)) || synced || certificate
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '卡券同步失败'
|
const message = error instanceof Error ? error.message : '卡券同步失败'
|
||||||
await markRedeemCertificateSyncFailed({ id: certificate.id, 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)
|
const verified = await markRedeemCertificateVerified(found.id)
|
||||||
|
|
||||||
@@ -301,6 +316,200 @@ export async function invalidateCertificatesForLocalOrder(input: {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核销失败后的自愈:回查平台侧券码,判断「其实已核销」或「换正确的券码重试一次」。
|
||||||
|
*
|
||||||
|
* 覆盖两种真实情况:
|
||||||
|
* 1. 首次调用超时/网络抖动,但平台其实已经核销成功 → 按对账结果标记本地已核销,
|
||||||
|
* 避免"平台已核销、我们报失败"的状态漂移(用户会重复点击)。
|
||||||
|
* 2. 我们存的是旧券码(平台改过名,历史订单没有 platform_cert_no)→ 用平台券码重试一次。
|
||||||
|
*
|
||||||
|
* 返回 true 表示可以继续走「标记已核销」;返回 false/null 表示无法自愈,按原错误抛出。
|
||||||
|
*/
|
||||||
|
async function tryRecoverVerify(
|
||||||
|
certificate: RedeemCertificateRow,
|
||||||
|
attemptedCertNo: string,
|
||||||
|
config: DouyinMashidaSourceConfig,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let rows: Array<Record<string, unknown>>
|
||||||
|
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<RedeemCertificateRow | null> {
|
||||||
|
let rows: Array<Record<string, unknown>>
|
||||||
|
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<Record<string, unknown>>,
|
||||||
|
ourCertNo: string,
|
||||||
|
): Record<string, unknown> | 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<string, unknown> | 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<RedeemCertificateRow> {
|
export async function findCertificateOrThrow(certNo: string): Promise<RedeemCertificateRow> {
|
||||||
const row = await findRedeemCertificateByCertNo(certNo)
|
const row = await findRedeemCertificateByCertNo(certNo)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import assert from 'node:assert/strict'
|
|||||||
import {
|
import {
|
||||||
REDEEM_CERT_CODE_PREFIX,
|
REDEEM_CERT_CODE_PREFIX,
|
||||||
generateRedeemCertificateCode,
|
generateRedeemCertificateCode,
|
||||||
|
resolveRedeemVerifyCertNo,
|
||||||
} from '../../repositories/redeem-certificate-repo.js'
|
} from '../../repositories/redeem-certificate-repo.js'
|
||||||
import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.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'
|
import { maskCertNo, normalizeOrderNo } from './redeem-service.js'
|
||||||
|
|
||||||
test('DYC 券码:前缀 + 16 位 Crockford Base32(去易混字符)', () => {
|
test('DYC 券码:前缀 + 16 位 Crockford Base32(去易混字符)', () => {
|
||||||
@@ -67,3 +73,56 @@ test('兑换后通知买家的文案:默认文案 / 带链接 / 自定义文
|
|||||||
'充值已完成,请点击使用:https://x.com/c',
|
'充值已完成,请点击使用: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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user