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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user