- sku_code 恒为内部 SKU(不再被英文 sku 覆写);英文 sku / 引流标题分别落 snapshot - 命中映射 → affiliate_dash;未命中不再兜底 manual_dispatch(无执行载体 = 待人工,不建任务) - 抖音映射支持「常量表 + 后台 skuMapping 增量」,新增商品不必发版 - 载体唯一守卫:命中自动发货的订单项不再建工单 - 券 cert_link 与命中解耦(/h5 一律按订单号拼);券写入工单 material_json.douyinMashidaVoucher - 新增/修正 16 例单测;后端全量 717 例 / 0 失败
867 lines
30 KiB
TypeScript
867 lines
30 KiB
TypeScript
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')}`
|
||
}
|