Files
order_site/apps/backend/src/services/redeem/certificate-service.ts
T
yml2213 4b081d88c5 feat(douyin-mashida): 抖音按 SKU 三层分类接入(对齐 91/快手)
- sku_code 恒为内部 SKU(不再被英文 sku 覆写);英文 sku / 引流标题分别落 snapshot
- 命中映射 → affiliate_dash;未命中不再兜底 manual_dispatch(无执行载体 = 待人工,不建任务)
- 抖音映射支持「常量表 + 后台 skuMapping 增量」,新增商品不必发版
- 载体唯一守卫:命中自动发货的订单项不再建工单
- 券 cert_link 与命中解耦(/h5 一律按订单号拼);券写入工单 material_json.douyinMashidaVoucher
- 新增/修正 16 例单测;后端全量 717 例 / 0 失败
2026-10-14 13:00:49 +08:00

867 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
findRedeemCertificateByCertNo,
findRedeemCertificateByOrder,
insertRedeemCertificate,
invalidateRedeemCertificatesByLocalOrder,
listRedeemCertificatesByLocalOrder,
markRedeemCertificatePlatformCert,
markRedeemCertificateSynced,
markRedeemCertificateSyncFailed,
markRedeemCertificateVerified,
resolveRedeemVerifyCertNo,
type RedeemCertificateRow,
} from '../../repositories/redeem-certificate-repo.js'
import {
createWorkOrderEvent,
getWorkOrderByOrderItemId,
updateWorkOrder,
} from '../../repositories/worker-platform/index.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { isPlainObject, type JsonObject } from '../../types/json.js'
import { resolveOrderItemClaimLink } from '../claim/claim-service.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import {
cancelDouyinMashidaCouponVerify,
fetchDouyinMashidaCoupons,
sendDouyinMashidaOrderMessage,
syncDouyinMashidaCoupons,
verifyDouyinMashidaCoupon,
} from '../platforms/douyin-mashida/gateway-client.js'
import {
buildDouyinMashidaBuyerClaimUrl,
isDouyinMashidaH5ClaimBase,
} from '../platforms/douyin-mashida/claim-link.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
isDouyinMashidaProvider,
} from '../platforms/douyin-mashida/order-mapper.js'
import {
DEFAULT_AFTER_VERIFY_MESSAGE,
getDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from '../platforms/douyin-mashida/source-config-service.js'
/**
* 兑换券业务(本期只服务抖音码速达)。
*
* 闭环:建单后**我们生成券码**(DYC + Crockford Base32)→ `coupons.syncV2` 交给平台展示给买家
* → 买家在 `https://<域名>/redeem` 输入订单号兑换 → `coupons.verifyV3` 上报核销。
*
* 皮肤/礼包类订单(命中 affiliate-dash 映射)**不走**上面的输入订单号兑换:
* 付款时按 `claimLinkBase` 把买家领取链接发给买家,买家在领取页输 UID → 扫码绑定 →
* 「绑定 UID == 填的 UID」即发货完成(见 claim/kuaishou-cloud-claim-service.ts),
* 完成时由该流程 best-effort 调 `verifyCertificateForOrderItem` 把平台券核销掉。
*
* 幂等:
* - 发券:`(provider, order_id)` 唯一,重复回调只会复用同一张券;
* - 同步:`sync_status='success'` 直接跳过,失败可重试;
* - 核销:先看本地 `status`,已是 `verified` 不再打平台。
*/
/** 回执给平台用的订单号:子订单号(快照里)优先、父单兜底。 */
export function resolveReceiptOrderId(order: OrderRow, orderItem: OrderItemRow): string {
const subOrderId = extractDouyinMashidaSubOrderId(orderItem.item_snapshot_json)
return String(subOrderId || order.platform_order_id || '').trim()
}
/** 发券 + 同步(幂等)。返回最终券状态。 */
export async function ensureCertificateForOrderItem(input: {
order: OrderRow
orderItem: OrderItemRow
config?: DouyinMashidaSourceConfig | undefined
}): Promise<RedeemCertificateRow> {
const config = input.config || getDouyinMashidaSourceConfig()
const provider = DOUYIN_MASHIDA_PROVIDER
const receiptOrderId = resolveReceiptOrderId(input.order, input.orderItem)
if (!receiptOrderId) {
throw createHttpError('抖音码速达订单缺少可回执的订单号(子订单号与父订单号都为空)', {
statusCode: 400,
errorCode: 'douyin_mashida_receipt_order_id_missing',
context: { orderId: input.order.id, orderItemId: input.orderItem.id },
})
}
// 该订单项已有领取 token 时 cert_link 用「买家领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。
const certLink = await resolveDouyinMashidaCertLink({
order: input.order,
orderItem: input.orderItem,
config,
})
let certificate = await findRedeemCertificateByOrder({ provider, orderId: receiptOrderId })
if (!certificate) {
certificate = await insertRedeemCertificate({
provider,
orderId: receiptOrderId,
parentOrderId: String(input.order.platform_order_id || '').trim(),
localOrderId: input.order.id,
orderItemId: input.orderItem.id,
certLink,
grantTime: nowIso(),
rawPayload: {
source: provider,
orderId: input.order.id,
orderItemId: input.orderItem.id,
skuCode: input.orderItem.sku_code,
skuName: input.orderItem.sku_name,
},
})
}
// 并发/重复插入时可能被别的请求抢先建好,这里兜底再查一次
certificate =
certificate || (await findRedeemCertificateByOrder({ provider, orderId: receiptOrderId }))
if (!certificate) {
throw createHttpError('兑换券创建失败', {
statusCode: 500,
errorCode: 'douyin_mashida_certificate_create_failed',
context: { orderId: input.order.id, receiptOrderId },
})
}
if (certificate.sync_status === 'success' || certificate.status === 'invalid') {
await attachDouyinMashidaCertificateToWorkOrder({
order: input.order,
orderItem: input.orderItem,
certificate,
})
return certificate
}
const syncedCertificate = await syncCertificateToPlatform(certificate, config, certLink)
await attachDouyinMashidaCertificateToWorkOrder({
order: input.order,
orderItem: input.orderItem,
certificate: syncedCertificate,
})
return syncedCertificate
}
/** 工单 material_json 里承载抖音券的键(与快手 `kuaishouIndustryVoucher` 同位)。 */
export const DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY = 'douyinMashidaVoucher'
export type DouyinMashidaWorkOrderVoucher = {
certNo: string
platformCertNo: string
certLink: string
status: string
syncStatus: string
grantTime: string
}
/** 券行 → 工单侧券快照(纯函数,回填与建单两处共用,避免口径漂移)。 */
export function buildDouyinMashidaWorkOrderVoucher(
certificate: Pick<
RedeemCertificateRow,
'cert_no' | 'platform_cert_no' | 'cert_link' | 'status' | 'sync_status' | 'grant_time'
>,
): DouyinMashidaWorkOrderVoucher {
return {
certNo: String(certificate.cert_no || '').trim(),
platformCertNo: String(certificate.platform_cert_no || '').trim(),
certLink: String(certificate.cert_link || '').trim(),
status: String(certificate.status || '').trim(),
syncStatus: String(certificate.sync_status || '').trim(),
grantTime: String(certificate.grant_time || '').trim(),
}
}
/**
* 把抖音券并入工单 `material_json`(对齐快手把电子凭证挂工单的做法)。
*
* 打手拿到的工单必须知道「交付什么券」,因此券一旦生成/同步就把它的快照写到工单上:
* `material_json.douyinMashidaVoucher = [{ certNo, platformCertNo, certLink, status, syncStatus, grantTime }]`。
*
* 幂等:按 `certNo` 合并,同券内容不变时返回 `changed: false`(调用方据此跳过写库/写事件)。
* 纯函数,便于单测。
*/
export function mergeDouyinMashidaVoucherIntoMaterial(
material: unknown,
voucher: DouyinMashidaWorkOrderVoucher,
): { material: JsonObject; changed: boolean } {
const source = isPlainObject(material) ? { ...material } : {}
const existingRows = Array.isArray(source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY])
? (source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] as unknown[])
: []
const existingVouchers = existingRows.filter(isPlainObject)
const current = existingVouchers.find((row) => String(row.certNo || '').trim() === voucher.certNo)
if (current && isSameDouyinMashidaVoucher(current, voucher)) {
return { material: source, changed: false }
}
source[DOUYIN_MASHIDA_VOUCHER_MATERIAL_KEY] = [
...existingVouchers.filter((row) => String(row.certNo || '').trim() !== voucher.certNo),
{ ...voucher },
]
return { material: source, changed: true }
}
function isSameDouyinMashidaVoucher(
current: JsonObject,
voucher: DouyinMashidaWorkOrderVoucher,
): boolean {
return (
String(current.platformCertNo || '') === voucher.platformCertNo &&
String(current.certLink || '') === voucher.certLink &&
String(current.status || '') === voucher.status &&
String(current.syncStatus || '') === voucher.syncStatus
)
}
/**
* 券生成/同步后回填工单 `material_json`(仅抖音;该订单项没有工单时静默跳过 = 正常情况)。
*
* best-effort:任何失败只记 warn,绝不影响发券主流程。
*/
async function attachDouyinMashidaCertificateToWorkOrder(input: {
order: OrderRow
orderItem: OrderItemRow
certificate: RedeemCertificateRow
}): Promise<void> {
if (!isDouyinMashidaProvider(input.order.provider)) {
return
}
try {
const workOrder = await getWorkOrderByOrderItemId(input.orderItem.id)
if (!workOrder) {
// 命中自动发货的订单项没有工单(载体唯一),属正常路径
return
}
const voucher = buildDouyinMashidaWorkOrderVoucher(input.certificate)
if (!voucher.certNo) {
return
}
const merged = mergeDouyinMashidaVoucherIntoMaterial(workOrder.material_json, voucher)
if (!merged.changed) {
return
}
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
material_json: JSON.stringify(merged.material),
updated_at: now,
})
if (!updated) {
return
}
await createWorkOrderEvent({
workOrderId: Number(workOrder.id),
actorType: 'system',
actorId: DOUYIN_MASHIDA_PROVIDER,
eventType: 'douyin_voucher_backfilled',
fromStatus: String(workOrder.status || ''),
toStatus: String(updated.status || workOrder.status || ''),
payloadJson: JSON.stringify({
certNo: voucher.certNo,
platformCertNo: voucher.platformCertNo,
syncStatus: voucher.syncStatus,
}),
now,
})
logIntegration('[douyin-mashida/certificate]', '券信息已回填工单 material_json', {
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
orderItemId: input.orderItem.id,
certNo: voucher.certNo,
})
} catch (error) {
logIntegration(
'[douyin-mashida/certificate]',
'券信息回填工单失败(不影响发券)',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
}
}
/**
* 解析本次同步要交给平台的 `cert_link`。
*
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
* - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理);
* - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接;
* - 拿不到可用链接 → 回退固定 `config.certLink`。
*
* 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号,
* 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service,
* 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。
*
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
*
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
*/
export async function resolveDouyinMashidaCertLink(input: {
order: OrderRow
orderItem: OrderItemRow
config: DouyinMashidaSourceConfig
}): Promise<string> {
const fallback = String(input.config.certLink || '').trim()
const claimLinkBase = String(input.config.claimLinkBase || '')
.trim()
.replace(/\/+$/, '')
if (!claimLinkBase) {
return fallback
}
const claimLink = await resolveOrderItemClaimLink({
orderId: input.order.id,
orderItemId: input.orderItem.id,
claimLinkBase: input.config.claimLinkBase,
})
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
if (!claimLink.taskId && claimLink.taskCount > 0) {
logIntegration(
'[douyin-mashida/certificate]',
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
taskCount: claimLink.taskCount,
},
{ level: 'warn' },
)
}
return resolveDouyinMashidaCertLinkFromClaim({
certLink: fallback,
claimLinkBase,
orderNo: resolveReceiptOrderId(input.order, input.orderItem),
tokenClaimUrl: claimLink.claimUrl,
})
}
/**
* `cert_link` 的**纯函数**判定(便于单测)。
*
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
*/
export function resolveDouyinMashidaCertLinkFromClaim(input: {
certLink: unknown
claimLinkBase: unknown
orderNo: unknown
tokenClaimUrl?: unknown
}): string {
const fallback = String(input.certLink ?? '').trim()
const base = String(input.claimLinkBase ?? '')
.trim()
.replace(/\/+$/, '')
if (!base) {
return fallback
}
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
claimLinkBase: base,
orderNo: input.orderNo,
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
tokenClaimUrl: input.tokenClaimUrl,
})
return claimUrl || fallback
}
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
async function syncCertificateToPlatform(
certificate: RedeemCertificateRow,
config: DouyinMashidaSourceConfig,
certLinkOverride?: string | undefined,
): Promise<RedeemCertificateRow> {
const certLink = String(certLinkOverride ?? config.certLink ?? '').trim()
if (!certLink) {
const message = '未配置抖音码速达兑换链接(certLink / claimLinkBase),券码暂不对外同步'
await markRedeemCertificateSyncFailed({ id: certificate.id, error: message })
logIntegration(
'[douyin-mashida/certificate]',
message,
{
certNo: certificate.cert_no,
orderId: certificate.order_id,
},
{ level: 'warn' },
)
return (
(await findRedeemCertificateByOrder({
provider: certificate.provider,
orderId: certificate.order_id,
})) || certificate
)
}
try {
await syncDouyinMashidaCoupons({
orderId: certificate.order_id,
certs: [{ certNo: certificate.cert_no, certLink, grantTime: formatGrantTime(nowIso()) }],
config,
})
const synced = await markRedeemCertificateSynced({
id: certificate.id,
certLink,
rawPayload: { source: certificate.provider, syncedAt: nowIso() },
})
logIntegration('[douyin-mashida/certificate]', '券码已同步给平台', {
certNo: certificate.cert_no,
orderId: certificate.order_id,
})
// 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 })
logIntegration(
'[douyin-mashida/certificate]',
'券码同步失败',
{ certNo: certificate.cert_no, orderId: certificate.order_id, error },
{ level: 'error' },
)
throw error
}
}
/**
* 领取页核销入口:按「本地订单 + 订单项」找到该订单的券码并核销(幂等)。
* 找不到券说明发券还没成功,明确报错让买家稍后重试,不要静默放过(否则平台侧券没核销)。
*/
export async function verifyCertificateForOrderItem(input: {
order: OrderRow
orderItem: OrderItemRow
config?: DouyinMashidaSourceConfig | undefined
}): Promise<RedeemCertificateRow> {
const receiptOrderId = resolveReceiptOrderId(input.order, input.orderItem)
const certificate = await findRedeemCertificateByOrder({
provider: DOUYIN_MASHIDA_PROVIDER,
orderId: receiptOrderId,
})
if (!certificate) {
throw createHttpError('该订单的券码尚未生成,请稍后重试', {
statusCode: 409,
errorCode: 'douyin_mashida_certificate_not_found',
context: { orderId: input.order.id, orderItemId: input.orderItem.id, receiptOrderId },
})
}
return verifyCertificate({ certNo: certificate.cert_no, config: input.config })
}
/**
* 核销上报(幂等):本地已核销直接返回;未同步成功的券不允许核销(否则平台侧没有这张券)。
*/
export async function verifyCertificate(input: {
certNo: string
config?: DouyinMashidaSourceConfig | undefined
}): Promise<RedeemCertificateRow> {
const config = input.config || getDouyinMashidaSourceConfig()
const certNo = String(input.certNo || '').trim()
const found = await findCertificateOrThrow(certNo)
if (found.status === 'verified') {
return found
}
if (found.status === 'invalid') {
throw createHttpError('该券码已作废,无法核销', {
statusCode: 409,
errorCode: 'douyin_mashida_certificate_invalid',
context: { certNo },
})
}
if (found.sync_status !== 'success') {
throw createHttpError('该券码尚未同步成功,暂不能核销', {
statusCode: 409,
errorCode: 'douyin_mashida_certificate_not_synced',
context: { certNo },
})
}
// ⚠️ 必须用**平台侧券码**: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)
logIntegration('[douyin-mashida/certificate]', '券码核销已上报', {
certNo,
orderId: found.order_id,
})
// 抖音码速达订单**不再**在核销后补发第二条消息:
// 买家在付款建单时已经收到「唯一那条领取链接」(见 douyin-mashida/notify-service.ts),
// 之后再发一条带链接的消息会让买家拿到第二条链接、分不清该点哪个。
// 其它平台(本表未来接入的 provider)行为保持不变:继续按 afterVerifyMessage/afterVerifyLink 通知。
if (shouldNotifyBuyerAfterVerify(verified || found)) {
await notifyBuyerAfterVerify({ certificate: verified || found, config }).catch((error) => {
logIntegration(
'[douyin-mashida/certificate]',
'兑换成功通知买家失败(兑换结果不受影响)',
{ certNo, orderId: found.order_id, error },
{ level: 'error' },
)
})
}
return verified || found
}
/**
* 核销后是否还要给买家发「去使用链接」消息。
*
* 抖音码速达:**不发**(买家已拿到领取链接;抖音皮肤/礼包单的领取链接本身就是完成入口)。
* 其它平台:保持原行为(发)。
*/
export function shouldNotifyBuyerAfterVerify(
certificate: Pick<RedeemCertificateRow, 'provider'> | null | undefined,
): boolean {
return String(certificate?.provider || '').trim() !== DOUYIN_MASHIDA_PROVIDER
}
/** 拼 order.message 的正文:默认文案 +(可选的)去使用链接。 */
export function buildAfterVerifyMessage(config: DouyinMashidaSourceConfig): string {
const message = String(config.afterVerifyMessage || '').trim() || DEFAULT_AFTER_VERIFY_MESSAGE
const link = String(config.afterVerifyLink || '').trim()
return link ? `${message},请点击使用:${link}` : message
}
async function notifyBuyerAfterVerify(input: {
certificate: RedeemCertificateRow
config: DouyinMashidaSourceConfig
}): Promise<void> {
const content = buildAfterVerifyMessage(input.config)
await sendDouyinMashidaOrderMessage({
orderId: input.certificate.order_id,
content,
config: input.config,
})
logIntegration('[douyin-mashida/certificate]', '已通知买家(order.message)', {
certNo: input.certificate.cert_no,
orderId: input.certificate.order_id,
hasLink: Boolean(String(input.config.afterVerifyLink || '').trim()),
})
}
/** 异常冲正:先取消核销(平台侧),再把本地状态回到 synced。 */
export async function cancelCertificateVerify(input: {
certNo: string
batchNo?: string | undefined
config?: DouyinMashidaSourceConfig | undefined
}): Promise<boolean> {
const config = input.config || getDouyinMashidaSourceConfig()
await cancelDouyinMashidaCouponVerify({
certNo: input.certNo,
batchNo: input.batchNo,
config,
})
return true
}
/** 退款/取消:把订单下未核销的券全部作废(已核销的留给人工处理)。 */
export async function invalidateCertificatesForLocalOrder(input: {
localOrderId: number | string
reason: string
}): Promise<number> {
const rows = await invalidateRedeemCertificatesByLocalOrder(input)
if (rows.length > 0) {
logIntegration('[douyin-mashida/certificate]', '订单退款/取消,已作废未核销券码', {
localOrderId: input.localOrderId,
reason: input.reason,
invalidatedCount: rows.length,
})
return rows.length
}
// 一张都没作废,说明要么没有券、要么**券已经核销过**。
// 后者是「买家先兑换再退款」的资损场景,必须留痕给人跟进,否则静默丢钱。
const existing = await listRedeemCertificatesByLocalOrder(input.localOrderId)
const verified = existing.filter((row) => row.status === 'verified')
if (verified.length > 0) {
logIntegration(
'[douyin-mashida/certificate]',
'退款/取消时券已核销:存在资损风险,需人工处理',
{
localOrderId: input.localOrderId,
reason: input.reason,
verifiedCount: verified.length,
certNos: verified.map((row) => row.cert_no),
},
{ level: 'warn' },
)
return 0
}
logIntegration('[douyin-mashida/certificate]', '退款/取消:该订单没有需要作废的券码', {
localOrderId: input.localOrderId,
reason: input.reason,
certificateCount: existing.length,
})
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> {
const row = await findRedeemCertificateByCertNo(certNo)
if (!row) {
throw createHttpError('券码不存在', {
statusCode: 404,
errorCode: 'douyin_mashida_certificate_not_found',
context: { certNo },
})
}
return row
}
/** 平台的 grant_time 形如 `YYYY-MM-DD HH:MM:SS`(北京时间)。 */
export function formatGrantTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) {
return ''
}
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(date)
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value || ''
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}:${pick('second')}`
}