feat(douyin-mashida): 抖音码速达最小接入后端(消息建单/券码同步/兑换核销/退款作废)

- 签名:出网 md5(secret+Σ(名+值)+secret) 与推送 md5(secret+data+pub_time+topic+secret)
  两套独立实现,含文档 golden 向量单测(与 91 大写 / affiliate_dash HMAC 都不通用)
- 进单:trade_TradePaid 推送 → 验签 → webhook_events 幂等 → order.orderDetail
  → upsertOrderFromSource(provider=douyin_mashida / platform=doudian)
- 精度:19 位订单号超出 JS 安全整数,JSON.parse 会把 p_id 抹成 ...000;
  故解析后从原始 JSON 文本用正则抓回精确数字(单测同时证明"不修会错")
- 券码:迁移 094 redeem_certificates((provider,order_id) 唯一做发券幂等)
  + DYC 券码生成 + coupons.syncV2 同步(子单号优先、父单兜底)
- 兑换:POST /api/v1/redeem/lookup|confirm,按 orderNo+IP 限流;
  核销成功后 order.message 通知买家(best-effort,失败只记错误日志)
- 退款/取消:作废未核销券;券已核销时打 WARN 资损告警(已消费不可回滚)
- 后台:GET/POST /api/v1/admin/platform-config/douyin-mashida,
  secret 出参置空 + 掩码,掩码回写不覆盖原值
- 消息路由挂在全局 body parser 之前(app.ts),验签必须用 body 原始字符串
This commit is contained in:
yml2213
2026-10-09 12:00:50 +08:00
parent 893388b37f
commit 72606796b3
21 changed files with 2806 additions and 0 deletions
@@ -0,0 +1,339 @@
import {
findRedeemCertificateByCertNo,
findRedeemCertificateByOrder,
insertRedeemCertificate,
invalidateRedeemCertificatesByLocalOrder,
listRedeemCertificatesByLocalOrder,
markRedeemCertificateSynced,
markRedeemCertificateSyncFailed,
markRedeemCertificateVerified,
type RedeemCertificateRow,
} from '../../repositories/redeem-certificate-repo.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import {
cancelDouyinMashidaCouponVerify,
sendDouyinMashidaOrderMessage,
syncDouyinMashidaCoupons,
verifyDouyinMashidaCoupon,
} from '../platforms/douyin-mashida/gateway-client.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
} 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` 上报核销。
*
* 幂等:
* - 发券:`(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 },
})
}
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: config.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') {
return certificate
}
return syncCertificateToPlatform(certificate, config)
}
/** 调 `coupons.syncV2` 把券码 + 固定兑换链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
async function syncCertificateToPlatform(
certificate: RedeemCertificateRow,
config: DouyinMashidaSourceConfig,
): Promise<RedeemCertificateRow> {
const certLink = String(config.certLink || '').trim()
if (!certLink) {
const message = '未配置抖音码速达兑换链接(certLink),券码暂不对外同步'
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,
})
return 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 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 },
})
}
await verifyDouyinMashidaCoupon({ certNo, config })
const verified = await markRedeemCertificateVerified(found.id)
logIntegration('[douyin-mashida/certificate]', '券码核销已上报', {
certNo,
orderId: found.order_id,
})
// 核销成功后把「去使用链接」发给买家(best-effort):
// 兑换本身已经成功,通知失败只记错误日志,不让用户看到兑换失败。
await notifyBuyerAfterVerify({ certificate: verified || found, config }).catch((error) => {
logIntegration(
'[douyin-mashida/certificate]',
'兑换成功通知买家失败(兑换结果不受影响)',
{ certNo, orderId: found.order_id, error },
{ level: 'error' },
)
})
return verified || found
}
/** 拼 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
}
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')}`
}
@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
REDEEM_CERT_CODE_PREFIX,
generateRedeemCertificateCode,
} from '../../repositories/redeem-certificate-repo.js'
import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
import { buildAfterVerifyMessage, formatGrantTime } from './certificate-service.js'
import { maskCertNo, normalizeOrderNo } from './redeem-service.js'
test('DYC 券码:前缀 + 16 位 Crockford Base32(去易混字符)', () => {
const code = generateRedeemCertificateCode(() => new Uint8Array(10).fill(255))
assert.ok(code.startsWith(REDEEM_CERT_CODE_PREFIX))
// 10 字节 = 80 bit → 16 个 Base32 字符
assert.equal(code.length, REDEEM_CERT_CODE_PREFIX.length + 16)
assert.match(code, /^DYC[0-9A-HJKMNP-TV-Z]{16}$/)
// 与快手券码同一套字母表:不含 I / L / O / U
assert.doesNotMatch(code.slice(3), /[ILOU]/)
})
test('DYC 券码:每次生成都不同(随机源)', () => {
const codes = new Set(Array.from({ length: 50 }, () => generateRedeemCertificateCode()))
assert.equal(codes.size, 50)
})
test('券码掩码:前 6 后 4,短码只留前 2', () => {
assert.equal(maskCertNo('DYC7K3M9QF2XR5TB'), 'DYC7K3****R5TB')
assert.equal(maskCertNo('DYC123'), 'DY****')
assert.equal(maskCertNo(''), '')
})
test('订单号校验:空/超长拒绝,正常放行并 trim', () => {
assert.equal(normalizeOrderNo(' 4837328765348567001 '), '4837328765348567001')
assert.throws(() => normalizeOrderNo(''), /请填写订单号/)
assert.throws(() => normalizeOrderNo(' '), /请填写订单号/)
assert.throws(() => normalizeOrderNo('x'.repeat(65)), /订单号格式不正确/)
})
test('grant_time 用北京时间格式化(Asia/Shanghai)', () => {
// 2026-10-08T06:30:05.000Z = 北京时间 14:30:05
assert.equal(formatGrantTime('2026-10-08T06:30:05.000Z'), '2026-10-08 14:30:05')
assert.equal(formatGrantTime('not-a-date'), '')
})
test('兑换后通知买家的文案:默认文案 / 带链接 / 自定义文案', () => {
const base = createDefaultDouyinMashidaSourceConfig()
// 默认:只有文案,没有链接
assert.equal(buildAfterVerifyMessage(base), '您的订单已兑换成功')
// 配了链接:文案后面拼「请点击使用:」
assert.equal(
buildAfterVerifyMessage({ ...base, afterVerifyLink: 'https://x.com/card' }),
'您的订单已兑换成功,请点击使用:https://x.com/card',
)
// 自定义文案优先
assert.equal(
buildAfterVerifyMessage({
...base,
afterVerifyMessage: '充值已完成',
afterVerifyLink: 'https://x.com/c',
}),
'充值已完成,请点击使用:https://x.com/c',
)
})
@@ -0,0 +1,298 @@
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByPlatformOrderId } from '../../repositories/order-repo.js'
import { listRedeemCertificatesByLocalOrder } from '../../repositories/redeem-certificate-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import {
DOUYIN_MASHIDA_PLATFORM,
DOUYIN_MASHIDA_PROVIDER,
} from '../platforms/douyin-mashida/order-mapper.js'
import { getDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
import { verifyCertificate } from './certificate-service.js'
/**
* 固定兑换链接的业务逻辑(`https://<域名>/redeem`,本期只服务抖音码速达)。
*
* 用户只输一个订单号,系统按状态分流:
* 未找到 → 订单号有误/尚未同步;未支付/已取消/已退款 → 不可兑换;
* 已核销 → 展示上次结果;可兑换 → 确认后调 `coupons.verifyV3` 上报核销。
*
* 安全(方案 §3.2):查询与确认分开、只返回脱敏摘要、限流在路由层按 orderNo+IP 做。
*/
export type RedeemOrderStatus =
| 'not_found'
| 'unpaid'
| 'cancelled'
| 'refunded'
| 'pending_certificate'
| 'redeemable'
| 'verified'
export type RedeemOrderView = {
status: RedeemOrderStatus
orderNo: string
/** 脱敏摘要(不含买家手机号/地址/open_id) */
summary: {
productName: string
quantity: number
amountFen: number
paidAt: string | null
} | null
message: string
/** 已核销时回显(券码在页面上要掩码展示) */
credential: { certNo: string; maskedCertNo: string; verifiedAt: string | null } | null
}
/** 查询:只给摘要与「下一步能不能兑换」,不产生任何副作用。 */
export async function lookupRedeemOrder(input: { orderNo: string }): Promise<RedeemOrderView> {
const orderNo = normalizeOrderNo(input.orderNo)
const context = await resolveRedeemContext(orderNo)
return buildView(orderNo, context)
}
/** 确认兑换:核销上报(幂等),成功后回显券码。 */
export async function confirmRedeemOrder(input: { orderNo: string }): Promise<RedeemOrderView> {
const orderNo = normalizeOrderNo(input.orderNo)
const context = await resolveRedeemContext(orderNo)
const view = buildView(orderNo, context)
if (view.status !== 'redeemable' || !context.certificate) {
// 不可兑换时把当前状态原样返回,交给前端提示(不抛错,避免用户看到 500)
return view
}
const verified = await verifyCertificate({ certNo: context.certificate.cert_no })
logIntegration('[redeem]', '订单兑换完成(已上报核销)', {
orderNo,
localOrderId: context.order?.id || null,
certNo: verified.cert_no,
})
return {
status: 'verified',
orderNo,
summary: view.summary,
message: '兑换成功,已为您核销',
credential: {
certNo: verified.cert_no,
maskedCertNo: maskCertNo(verified.cert_no),
verifiedAt: verified.verified_at,
},
}
}
type RedeemContext = {
order: OrderRow | null
items: OrderItemRow[]
certificate: {
cert_no: string
status: string
sync_status: string
verified_at: string | null
} | null
verifiedCertificate: {
cert_no: string
verified_at: string | null
} | null
/** 被退款/取消作废掉的券(sync_error 里存了作废原因) */
invalidCertificate: {
cert_no: string
reason: string
} | null
}
async function resolveRedeemContext(orderNo: string): Promise<RedeemContext> {
const order = await findLatestOrderByPlatformOrderId({
provider: DOUYIN_MASHIDA_PROVIDER,
platform: DOUYIN_MASHIDA_PLATFORM,
platformOrderId: orderNo,
})
if (!order) {
return {
order: null,
items: [],
certificate: null,
verifiedCertificate: null,
invalidCertificate: null,
}
}
const certificates = await listRedeemCertificatesByLocalOrder(order.id)
const verified = certificates.find((row) => row.status === 'verified')
const redeemable = certificates.find((row) => row.status === 'issued' || row.status === 'synced')
const invalid = certificates.find((row) => row.status === 'invalid')
const items = await listOrderItemsByOrderId(order.id)
return {
order,
items,
certificate: redeemable
? {
cert_no: redeemable.cert_no,
status: redeemable.status,
sync_status: redeemable.sync_status,
verified_at: redeemable.verified_at,
}
: null,
verifiedCertificate: verified
? { cert_no: verified.cert_no, verified_at: verified.verified_at }
: null,
invalidCertificate: invalid
? { cert_no: invalid.cert_no, reason: String(invalid.sync_error || '') }
: null,
}
}
function buildView(orderNo: string, context: RedeemContext): RedeemOrderView {
const { order } = context
if (!order) {
return {
status: 'not_found',
orderNo,
summary: null,
message: '订单号有误,或订单尚未同步,请稍后再试',
credential: null,
}
}
const summary = buildSummary(order, context.items)
if (
String(order.pay_status || '') === 'refunded' ||
String(order.order_status || '') === 'refunded'
) {
return {
status: 'refunded',
orderNo,
summary,
message: '订单已退款,无法兑换',
credential: null,
}
}
if (String(order.order_status || '') === 'closed') {
return {
status: 'cancelled',
orderNo,
summary,
message: '订单已取消,无法兑换',
credential: null,
}
}
if (String(order.pay_status || '') !== 'paid') {
return {
status: 'unpaid',
orderNo,
summary,
message: '订单尚未支付,请付款后再来兑换',
credential: null,
}
}
if (context.verifiedCertificate) {
return {
status: 'verified',
orderNo,
summary,
message: '该订单已兑换',
credential: {
certNo: context.verifiedCertificate.cert_no,
maskedCertNo: maskCertNo(context.verifiedCertificate.cert_no),
verifiedAt: context.verifiedCertificate.verified_at,
},
}
}
// 券已作废(退款/取消):这是**终止态**,必须明确告诉用户不能再兑换。
// 注意要排在「等待发券」之前,否则退款单会被误报成「券准备中」(永远等不到)。
// 作废原因存在 sync_error 里,用它区分退款还是取消。
if (context.invalidCertificate) {
const isRefund = /refund/i.test(context.invalidCertificate.reason)
return {
status: isRefund ? 'refunded' : 'cancelled',
orderNo,
summary,
message: isRefund ? '订单已退款,兑换券已作废' : '订单已取消,兑换券已作废',
credential: null,
}
}
if (!context.certificate) {
return {
status: 'pending_certificate',
orderNo,
summary,
message: '正在为该订单准备兑换券,请稍后再试',
credential: null,
}
}
if (context.certificate.sync_status !== 'success') {
return {
status: 'pending_certificate',
orderNo,
summary,
message: '兑换券还在同步中,请稍后再试',
credential: null,
}
}
return {
status: 'redeemable',
orderNo,
summary,
message: '请确认订单信息后完成兑换',
credential: null,
}
}
function buildSummary(order: OrderRow, items: OrderItemRow[]) {
const firstItem = items[0] || null
const quantity = items.reduce((total, item) => total + Math.max(1, Number(item.quantity || 1)), 0)
return {
productName: String(firstItem?.sku_name || order.shop_name || '').trim(),
quantity: Math.max(1, quantity),
amountFen: Number(order.total_amount || 0),
paidAt: order.paid_at || null,
}
}
export function normalizeOrderNo(value: unknown): string {
const orderNo = String(value ?? '').trim()
if (!orderNo) {
throw createHttpError('请填写订单号', {
statusCode: 400,
errorCode: 'redeem_order_no_required',
})
}
if (orderNo.length > 64) {
throw createHttpError('订单号格式不正确', {
statusCode: 400,
errorCode: 'redeem_order_no_invalid',
})
}
return orderNo
}
/** 券码在页面上掩码展示(前 6 后 4),避免整串被截图外传。 */
export function maskCertNo(certNo: unknown): string {
const normalized = String(certNo || '').trim()
if (normalized.length <= 10) {
return normalized ? `${normalized.slice(0, 2)}****` : ''
}
return `${normalized.slice(0, 6)}****${normalized.slice(-4)}`
}
export function getRedeemCertLink(): string {
return String(getDouyinMashidaSourceConfig().certLink || '').trim()
}