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 { 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 { 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 { 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 { 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 { 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 { 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 | 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 { 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 { 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 { 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 { 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) 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')}` }