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
+8
View File
@@ -7,9 +7,11 @@ import affiliateDashRouter from './routes/affiliate-dash.js'
import collectRouter from './routes/collect.js'
import filesRouter from './routes/files.js'
import claimsRouter from './routes/claims.js'
import douyinMashidaRouter from './routes/douyin-mashida.js'
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
import open91Router from './routes/open-91.js'
import pddExtensionRouter from './routes/pdd-extension.js'
import redeemRouter from './routes/redeem.js'
import shortLinksRouter from './routes/short-links.js'
import workerRouter from './routes/worker.js'
import { accessLogMiddleware } from './middleware/access-log.js'
@@ -34,6 +36,11 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use(accessLogMiddleware)
app.use(createCorsMiddleware(config))
// 抖音码速达消息推送必须在全局 body parser **之前**挂载:
// 它的签名基于 `data` 的原始 JSON 字符串,express.json/urlencoded 会改变这个字符串。
app.use('/api/v1/open/douyin-mashida', douyinMashidaRouter)
app.use(
express.json({
limit: '2mb',
@@ -102,6 +109,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
app.use('/api/v1/open/affiliate-dash', affiliateDashRouter)
app.use('/api/v1/open/91', open91Router)
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
app.use('/api/v1/redeem', redeemRouter)
app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/collect', collectRouter)
app.use('/api/v1/pdd/extension', pddExtensionRouter)
@@ -12,4 +12,6 @@ export const APP_CONFIG_KEYS = {
kuaishouIndustrySource: 'kuaishou_industry_source',
affiliateDash: 'affiliate_dash',
pddSource: 'pdd_source',
/** 抖音码速达(抖店中转开放平台)接入配置:uid/secret/baseUrl/certLink 等 */
douyinMashida: 'douyin_mashida',
} as const
@@ -0,0 +1,57 @@
-- 094_redeem_certificates.sql —— 兑换券表(抖音码速达最小接入,多平台预留 provider 列)。
--
-- 背景:抖音码速达把抖店订单推给我们履约,我们**自己生成券码**(DYC 前缀)通过
-- `coupons.syncV2` 交给平台展示给买家;买家打开固定兑换链接 `https://<域名>/redeem`
-- 输入订单号兑换,我们再调 `coupons.verifyV3` 上报核销。
--
-- 为什么独立建表而**不复用** kuaishou_industry_vouchers:
-- 那张表是「快手电子凭证」(字段 oid/token/seller_id,核销走快手 OpenAPI),
-- 与本表的「我们自发的兑换券」语义不同,混用会污染既有状态机与发码回调重试逻辑。
--
-- 量级预期:与订单量同阶(一单一券),需要按 order / cert_no 精确检索。
CREATE TABLE IF NOT EXISTS redeem_certificates (
id BIGSERIAL PRIMARY KEY,
-- 平台标识:本期只会有 douyin_mashida;列保留是为了以后接第二个平台时不用改表
provider TEXT NOT NULL,
-- DYC + Crockford Base32(全局唯一)
cert_no TEXT NOT NULL,
-- 回执给平台用的订单号(syncV2 / order.message 的 order_id,子订单号优先、父单兜底)
order_id TEXT NOT NULL,
-- 平台父订单号,便于排查
parent_order_id TEXT NOT NULL DEFAULT '',
-- 本地关联(兑换页按订单号查到本地订单后反查券)
local_order_id BIGINT,
order_item_id BIGINT,
-- 发给平台的固定兑换链接(配置项 certLink,不硬编码)
cert_link TEXT NOT NULL DEFAULT '',
-- issued 已生成 / synced 已同步给平台 / verified 已核销 / invalid 已作废(退款、取消)
status TEXT NOT NULL DEFAULT 'issued'
CHECK (status IN ('issued', 'synced', 'verified', 'invalid')),
sync_status TEXT NOT NULL DEFAULT 'pending'
CHECK (sync_status IN ('pending', 'success', 'failed')),
sync_error TEXT NOT NULL DEFAULT '',
grant_time TIMESTAMPTZ,
synced_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 券码全局唯一:兑换页/核销都按券码反查
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_certificates_cert_no
ON redeem_certificates(cert_no);
-- 同一平台同一订单号只允许一张券:发券幂等(平台重复推消息不会重复发券)
CREATE UNIQUE INDEX IF NOT EXISTS idx_redeem_certificates_order
ON redeem_certificates(provider, order_id);
-- 兑换页按本地订单反查券(订单号查询命中后取券状态)
CREATE INDEX IF NOT EXISTS idx_redeem_certificates_local_order
ON redeem_certificates(local_order_id);
COMMENT ON TABLE redeem_certificates IS '兑换券(我方生成、经第三方平台展示给买家的券码;抖音码速达最小接入)';
COMMENT ON COLUMN redeem_certificates.order_id IS 'syncV2/order.message 用的订单号(子订单号优先、父单兜底)';
COMMENT ON COLUMN redeem_certificates.status IS 'issued 已生成 / synced 已同步 / verified 已核销 / invalid 已作废';
COMMENT ON COLUMN redeem_certificates.cert_link IS '固定兑换链接(来自配置 certLink,发出去就收不回来,换域名只影响新券)';
@@ -0,0 +1,233 @@
import crypto from 'node:crypto'
import { query } from '../db/client.js'
/**
* 兑换券仓储(表 `redeem_certificates`,迁移 094)。
*
* 幂等键是 `(provider, order_id)`:平台重复推消息 / 我们重试发券时,
* 同一订单只会有一张券(重复插入直接返回 null,由上层取已有券)。
*/
export const REDEEM_CERT_CODE_PREFIX = 'DYC'
const CERT_CODE_RANDOM_BYTES = 10
const CERT_CODE_MAX_GENERATE_ATTEMPTS = 5
const CROCKFORD_BASE32_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
export type RedeemCertificateStatus = 'issued' | 'synced' | 'verified' | 'invalid'
export type RedeemCertificateSyncStatus = 'pending' | 'success' | 'failed'
export type RedeemCertificateRow = {
id: number
provider: string
cert_no: string
order_id: string
parent_order_id: string
local_order_id: number | null
order_item_id: number | null
cert_link: string
status: RedeemCertificateStatus
sync_status: RedeemCertificateSyncStatus
sync_error: string
grant_time: string | null
synced_at: string | null
verified_at: string | null
raw_payload_json: unknown
created_at: string
updated_at: string
}
export function generateRedeemCertificateCode(
randomBytes: (size: number) => Uint8Array = crypto.randomBytes,
): string {
return `${REDEEM_CERT_CODE_PREFIX}${encodeCrockfordBase32(randomBytes(CERT_CODE_RANDOM_BYTES))}`
}
export async function findRedeemCertificateByOrder(input: {
provider: string
orderId: string
}): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`SELECT * FROM redeem_certificates WHERE provider = $1 AND order_id = $2 LIMIT 1`,
[String(input.provider || '').trim(), String(input.orderId || '').trim()],
)
return result.rows[0] || null
}
export async function findRedeemCertificateByCertNo(
certNo: string,
): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`SELECT * FROM redeem_certificates WHERE cert_no = $1 LIMIT 1`,
[String(certNo || '').trim()],
)
return result.rows[0] || null
}
export async function listRedeemCertificatesByLocalOrder(
localOrderId: number | string,
): Promise<RedeemCertificateRow[]> {
const result = await query<RedeemCertificateRow>(
`SELECT * FROM redeem_certificates WHERE local_order_id = $1 ORDER BY id ASC`,
[Number(localOrderId)],
)
return result.rows
}
/**
* 幂等插入:同一 (provider, order_id) 已存在时返回 null(调用方改取已有券)。
* 券码随机,撞唯一键(23505)时自动重试换码。
*/
export async function insertRedeemCertificate(input: {
provider: string
orderId: string
parentOrderId?: string | undefined
localOrderId?: number | null | undefined
orderItemId?: number | null | undefined
certLink: string
grantTime?: string | null | undefined
rawPayload?: unknown
}): Promise<RedeemCertificateRow | null> {
for (let attempt = 1; attempt <= CERT_CODE_MAX_GENERATE_ATTEMPTS; attempt += 1) {
const certNo = generateRedeemCertificateCode()
try {
const result = await query<RedeemCertificateRow>(
`
INSERT INTO redeem_certificates (
provider, cert_no, order_id, parent_order_id, local_order_id, order_item_id,
cert_link, status, sync_status, grant_time, raw_payload_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'issued', 'pending', $8, $9::jsonb, NOW(), NOW())
ON CONFLICT (provider, order_id) DO NOTHING
RETURNING *
`,
[
String(input.provider || '').trim(),
certNo,
String(input.orderId || '').trim(),
String(input.parentOrderId || '').trim(),
input.localOrderId == null ? null : Number(input.localOrderId),
input.orderItemId == null ? null : Number(input.orderItemId),
String(input.certLink || '').trim(),
input.grantTime || null,
JSON.stringify(input.rawPayload || {}),
],
)
// 没有 RETURNING 行 = 撞了 (provider, order_id):券已存在,交给上层取已有券
return result.rows[0] || null
} catch (error) {
// 券码随机、全局唯一:极小概率撞码时换一个重试
if (attempt < CERT_CODE_MAX_GENERATE_ATTEMPTS && isUniqueViolation(error)) {
continue
}
throw error
}
}
return null
}
function isUniqueViolation(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
String((error as { code?: unknown }).code || '') === '23505',
)
}
export async function markRedeemCertificateSynced(input: {
id: number | string
certLink?: string | undefined
rawPayload?: unknown
}): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET status = CASE WHEN status = 'issued' THEN 'synced' ELSE status END,
sync_status = 'success',
sync_error = '',
cert_link = COALESCE(NULLIF($2, ''), cert_link),
synced_at = NOW(),
raw_payload_json = CASE WHEN $3::jsonb = '{}'::jsonb THEN raw_payload_json ELSE $3::jsonb END,
updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[Number(input.id), String(input.certLink || '').trim(), JSON.stringify(input.rawPayload || {})],
)
return result.rows[0] || null
}
export async function markRedeemCertificateSyncFailed(input: {
id: number | string
error: string
}): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET sync_status = 'failed', sync_error = $2, updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[Number(input.id), String(input.error || '').slice(0, 500)],
)
return result.rows[0] || null
}
/** 核销上报成功:`verified`(已核销的重复调用不再改状态,保持幂等)。 */
export async function markRedeemCertificateVerified(
id: number | string,
): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET status = 'verified', verified_at = COALESCE(verified_at, NOW()), updated_at = NOW()
WHERE id = $1
RETURNING *
`,
[Number(id)],
)
return result.rows[0] || null
}
/** 退款/取消:把该订单下未核销的券全部作废(已核销的不动,留给人工/售后处理)。 */
export async function invalidateRedeemCertificatesByLocalOrder(input: {
localOrderId: number | string
reason: string
}): Promise<RedeemCertificateRow[]> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET status = 'invalid',
sync_error = CASE WHEN sync_error = '' THEN $2 ELSE sync_error END,
updated_at = NOW()
WHERE local_order_id = $1 AND status <> 'verified' AND status <> 'invalid'
RETURNING *
`,
[Number(input.localOrderId), String(input.reason || '').slice(0, 500)],
)
return result.rows
}
function encodeCrockfordBase32(bytes: Uint8Array): string {
let bits = 0
let value = 0
let output = ''
for (const byte of bytes) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
output += CROCKFORD_BASE32_ALPHABET[(value >>> (bits - 5)) & 31] || ''
bits -= 5
}
}
if (bits > 0) {
output += CROCKFORD_BASE32_ALPHABET[(value << (5 - bits)) & 31] || ''
}
return output
}
@@ -6,6 +6,7 @@ import ninetyoneRouter from './platform-config/ninetyone.js'
import notificationsRouter from './platform-config/notifications.js'
import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js'
import affiliateDashRouter from './platform-config/affiliate-dash.js'
import douyinMashidaRouter from './platform-config/douyin-mashida.js'
import pddRouter from './platform-config/pdd.js'
const router = Router()
@@ -17,5 +18,7 @@ router.use('/platform-config', ninetyoneRouter)
router.use('/platform-config', fulfillmentRoutingRouter)
router.use('/platform-config', affiliateDashRouter)
router.use('/platform-config', pddRouter)
// 抖音码速达:注册在 requireAdminRoles(['admin']) 之后,自动继承 admin 守卫
router.use('/platform-config', douyinMashidaRouter)
export default router
@@ -0,0 +1,49 @@
import { Router } from 'express'
import {
getAdminDouyinMashidaConfig,
saveAdminDouyinMashidaConfig,
} from '../../../services/admin/platform-config/douyin-mashida-service.js'
import { createJsonHandler } from '../session.js'
import type { JsonRecord } from '../../../types/json.js'
const router = Router()
router.get(
'/douyin-mashida',
createJsonHandler(() => getAdminDouyinMashidaConfig(), {
successMessage: 'ok',
errorMessage: '读取抖音码速达配置失败',
scope: '[admin/platform-config/douyin-mashida]',
}),
)
router.post(
'/douyin-mashida',
createJsonHandler((req) => saveAdminDouyinMashidaConfig(req.body as JsonRecord), {
successMessage: '抖音码速达配置已保存',
errorMessage: '保存抖音码速达配置失败',
scope: '[admin/platform-config/douyin-mashida]',
audit: (_req, data) => {
const config = data as JsonRecord
return {
action: 'platform_douyin_mashida_config_updated',
targetType: 'platform_config',
targetId: 'douyin_mashida',
// 审计只记摘要,绝不写密钥
data: {
enabled: config.enabled !== false,
hasUid: Boolean(config.uid),
hasSecret: Boolean(config.hasSecret),
hasCertLink: Boolean(config.certLink),
hasAfterVerifyLink: Boolean(config.afterVerifyLink),
messageType: Number(config.messageType || 0),
handledTopicCount: Array.isArray(config.handledTopics) ? config.handledTopics.length : 0,
},
}
},
}),
)
export default router
+67
View File
@@ -0,0 +1,67 @@
import express, { Router } from 'express'
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
import { handleDouyinMashidaMessage } from '../services/platforms/douyin-mashida/notify-service.js'
import { sendRouteError } from '../utils/http.js'
import { createRequestId, logIntegration } from '../utils/logger.js'
/**
* 抖音码速达消息推送入口。
*
* ⚠️ 这个路由必须在 `app.ts` 的 `express.json()` / `express.urlencoded()` **之前**挂载:
* 平台签名用的是 `data` 的**原始 JSON 字符串**,任何全局 body parser 都会破坏它。
* 这里用 `express.text({ type: () => true })` 自己收原文(与快手/91 的入站不同,那两个不需要 rawBody)。
*
* 推送形态:`POST /api/v1/open/douyin-mashida/message?topic=&pub_time=&sign=`,body 为 data 的 JSON 字符串。
*/
const router = Router()
const messageRateLimit = createRateLimitMiddleware({
scope: 'douyinMashidaMessage',
windowMs: 60_000,
max: 300,
})
const captureRawBody = express.text({ type: () => true, limit: '2mb' })
router.all('/message', messageRateLimit, captureRawBody, async (req, res) => {
const requestId = createRequestId('dym')
const startedAt = Date.now()
const topic = String(req.query.topic || '').trim()
const pubTime = String(req.query.pub_time || req.query.pubTime || '').trim()
const sign = String(req.query.sign || '').trim()
const rawBody = typeof req.body === 'string' ? req.body : ''
logIntegration('[douyin-mashida/message]', '收到码速达消息推送', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
ip: req.ip,
topic,
pubTime,
bodyLength: rawBody.length,
})
try {
const result = await handleDouyinMashidaMessage({ topic, pubTime, sign, rawBody })
logIntegration('[douyin-mashida/message]', '消息推送处理完成', {
requestId,
durationMs: Date.now() - startedAt,
...result,
})
// 平台侧只要求「处理成功」;具体期望的响应体在联调时确认(失败时用 4xx/5xx 触发平台重推)
res.status(200).json({ code: 0, message: 'success', data: { action: result.action } })
} catch (error) {
logIntegration(
'[douyin-mashida/message]',
'消息推送处理失败',
{ requestId, durationMs: Date.now() - startedAt, topic, error },
{ level: 'error' },
)
sendRouteError(res, error, '抖音码速达消息处理失败', '[douyin-mashida/message]')
}
})
export default router
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express'
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js'
import { confirmRedeemOrder, lookupRedeemOrder } from '../services/redeem/redeem-service.js'
import { createRouteHandler } from '../utils/http.js'
/**
* 固定兑换链接的后端接口(前端页面在 `https://<域名>/redeem`)。
*
* 公开接口(无需登录),但按「订单号 + IP」限流:这是无 token 方案的第一道防线
* (见 docs/抖音-码速达/05-最小接入方案分析.md §3.2)。
* 与 `/collect`(routes/collect.ts)完全同型。
*/
const router = Router()
const redeemRateLimit = createRateLimitMiddleware({
scope: 'redeem',
windowMs: 60_000,
max: 60,
key: getBodyFieldRateLimitKey('orderNo'),
})
router.post(
'/lookup',
redeemRateLimit,
createRouteHandler((req) => lookupRedeemOrder({ orderNo: req.body?.orderNo }), {
successMessage: 'ok',
errorMessage: '查询订单失败',
scope: '[redeem/lookup]',
}),
)
router.post(
'/confirm',
redeemRateLimit,
createRouteHandler((req) => confirmRedeemOrder({ orderNo: req.body?.orderNo }), {
successMessage: '兑换完成',
errorMessage: '兑换失败',
scope: '[redeem/confirm]',
}),
)
export default router
@@ -0,0 +1,75 @@
import type { JsonObject } from '../../../types/json.js'
import { maskSecret } from '../../../utils/masking.js'
import {
getDouyinMashidaSourceConfig,
saveDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from '../../platforms/douyin-mashida/source-config-service.js'
/**
* 后台「抖音码速达」配置读写。
*
* ⚠️ 凭据遵守仓库既有约定(照抄 `kuaishou-industry-service.ts:106-133/216-244`):
* 出参**不回显明文 secret**,只给 `secretMasked` + `hasSecret`;
* 入参如果不填或填的是掩码值,**保留原值**,不能被空串覆盖。
*/
const SECRET_FIELDS = ['secret'] as const
export function getAdminDouyinMashidaConfig() {
return mapAdminDouyinMashidaConfig(getDouyinMashidaSourceConfig())
}
export async function saveAdminDouyinMashidaConfig(payload: JsonObject = {}) {
const current = getDouyinMashidaSourceConfig()
const saved = await saveDouyinMashidaSourceConfig({
...payload,
secret: resolveSecretValue(payload, current),
})
return mapAdminDouyinMashidaConfig(saved)
}
function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) {
return {
enabled: config.enabled,
baseUrl: config.baseUrl,
uid: config.uid,
secret: '',
secretMasked: maskSecret(config.secret),
hasSecret: Boolean(config.secret),
certLink: config.certLink,
messageType: config.messageType,
afterVerifyLink: config.afterVerifyLink,
afterVerifyMessage: config.afterVerifyMessage,
timeoutMs: config.timeoutMs,
handledTopics: config.handledTopics,
/** 联调自查:uid / secret / 兑换链接 三项都齐了才算配置完整 */
ready: Boolean(config.enabled && config.uid && config.secret && config.certLink),
}
}
function resolveSecretValue(payload: JsonObject, current: DouyinMashidaSourceConfig): string {
const text = String(payload[SECRET_FIELDS[0]] ?? '').trim()
if (!text || isMaskedSecretValue(text, current.secret)) {
return current.secret
}
return text
}
/** 空串 / 含 `****` / 等于当前掩码值 → 视为「未修改」。 */
export function isMaskedSecretValue(value: unknown, currentSecret = ''): boolean {
const text = String(value || '').trim()
if (!text) {
return true
}
if (text.includes('****') || text.includes('***')) {
return true
}
if (currentSecret && text === maskSecret(currentSecret)) {
return true
}
return false
}
@@ -0,0 +1,109 @@
import type { JsonObject } from '../../../types/json.js'
import { douyinMashidaGatewayRequest } from './http-client.js'
import type { DouyinMashidaSourceConfig } from './source-config-service.js'
import { getDouyinMashidaSourceConfig } from './source-config-service.js'
/**
* 抖音码速达业务接口薄封装(最小接入只用到这 4 个)。
*
* 公共参数(uid/timestamp/sign)在 http-client 里统一补齐,这里只传业务参数。
* 以后若换成直连抖音官方网关,**只改这一个文件**即可。
*/
export type DouyinMashidaCallOptions = {
config?: DouyinMashidaSourceConfig | undefined
timeoutMs?: number | undefined
}
/** 订单详情(高级功能):拿商品明细、买家留言、收件人信息。 */
export async function fetchDouyinMashidaOrderDetail(
input: { shopOrderId: string } & DouyinMashidaCallOptions,
): Promise<JsonObject> {
const response = await douyinMashidaGatewayRequest({
method: 'order.orderDetail',
params: { shop_order_id: String(input.shopOrderId || '').trim() },
config: input.config,
timeoutMs: input.timeoutMs,
})
return isPlainObject(response.data) ? response.data : {}
}
/** 卡券同步(基础功能):把我们的券码与固定兑换链接交给平台展示给买家。 */
export async function syncDouyinMashidaCoupons(
input: {
orderId: string
certs: Array<{ certNo: string; certLink: string; grantTime?: string }>
} & DouyinMashidaCallOptions,
): Promise<void> {
const certList = (input.certs || []).map((cert) => ({
cert_no: String(cert.certNo || '').trim(),
cert_link: String(cert.certLink || '').trim(),
grant_time: String(cert.grantTime || '').trim(),
}))
await douyinMashidaGatewayRequest({
method: 'coupons.syncV2',
params: {
order_id: String(input.orderId || '').trim(),
// 文档要求 cert_list 以 JSON 字符串整体作为参数值提交
cert_list: JSON.stringify(certList),
},
config: input.config,
timeoutMs: input.timeoutMs,
})
}
/** 卡券核销(高级功能):只认券码,所以券码必须能反查本地订单。 */
export async function verifyDouyinMashidaCoupon(
input: { certNo: string } & DouyinMashidaCallOptions,
): Promise<void> {
await douyinMashidaGatewayRequest({
method: 'coupons.verifyV3',
params: { cert_no: String(input.certNo || '').trim() },
config: input.config,
timeoutMs: input.timeoutMs,
})
}
/** 卡券取消核销(异常兜底用,最小接入不接后台按钮)。 */
export async function cancelDouyinMashidaCouponVerify(
input: { certNo: string; batchNo?: string | undefined } & DouyinMashidaCallOptions,
): Promise<void> {
await douyinMashidaGatewayRequest({
method: 'coupons.cancelVerify',
params: {
cert_no: String(input.certNo || '').trim(),
...(input.batchNo ? { batch_no: String(input.batchNo).trim() } : {}),
},
config: input.config,
timeoutMs: input.timeoutMs,
})
}
/** 订单消息(把「去使用链接」发给买家)。 */
export async function sendDouyinMashidaOrderMessage(
input: {
orderId: string
content: string
messageType?: number | undefined
} & DouyinMashidaCallOptions,
): Promise<void> {
const messageType = Number(input.messageType || 0) || getDouyinMashidaSourceConfig().messageType
await douyinMashidaGatewayRequest({
method: 'order.message',
params: {
order_id: String(input.orderId || '').trim(),
content: String(input.content || '').trim(),
message_type: String(messageType),
},
config: input.config,
timeoutMs: input.timeoutMs,
})
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -0,0 +1,165 @@
import { asJsonObject, isPlainObject, type JsonObject } from '../../../types/json.js'
import { createHttpError } from '../../../utils/http.js'
import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js'
import {
assertDouyinMashidaConfig,
type DouyinMashidaSourceConfig,
} from './source-config-service.js'
import { DOUYIN_MASHIDA_GATEWAY_PATH, buildGatewayRequestParams } from './sign.js'
/**
* 抖音码速达网关请求封装。
*
* 协议:`POST {baseUrl}/api/openplatform/gateway`,`application/x-www-form-urlencoded`,
* 公共参数 uid/method/timestamp/sign + 业务参数;返回 `{ code, message, data }`,
* `code === '10000'` 为成功(文档里是字符串)。
*/
export type DouyinMashidaGatewayResponse = {
code: string
message: string
data: unknown
raw: JsonObject
}
export async function douyinMashidaGatewayRequest(input: {
method: string
params?: Record<string, unknown> | undefined
config?: DouyinMashidaSourceConfig | undefined
timeoutMs?: number | undefined
}): Promise<DouyinMashidaGatewayResponse> {
const config = input.config || assertDouyinMashidaConfig()
const timeoutMs = Math.max(1, Number(input.timeoutMs || config.timeoutMs) || config.timeoutMs)
const requestUrl = new URL(DOUYIN_MASHIDA_GATEWAY_PATH, `${config.baseUrl}/`).toString()
const signedParams = buildGatewayRequestParams({
uid: config.uid,
method: input.method,
secret: config.secret,
params: input.params,
})
const body = new URLSearchParams(signedParams).toString()
const packetId = createRequestId('dym')
const startedAt = Date.now()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
logExternalHttpPacket('[douyin-mashida/http]', '发送网关请求', {
packetId,
method: 'POST',
pathname: DOUYIN_MASHIDA_GATEWAY_PATH,
requestUrl,
timeoutMs,
request: {
headers: { 'content-type': 'application/x-www-form-urlencoded' },
// 参数里的 sign 是派生值,日志层会按敏感键脱敏;这里只记业务参数摘要
body: { uid: config.uid, method: input.method, params: input.params },
rawBody: body,
},
})
let responseText = ''
let status = 0
try {
const response = await fetch(requestUrl, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
signal: controller.signal,
})
status = response.status
responseText = await response.text()
} catch (error) {
const aborted = isAbortError(error)
const message = aborted
? `抖音码速达网关请求超时(${timeoutMs}ms)`
: `抖音码速达网关请求失败:${error instanceof Error ? error.message : String(error)}`
logExternalHttpPacket(
'[douyin-mashida/http]',
'网关请求异常',
{ packetId, method: input.method, status, error },
{ level: 'warn' },
)
throw createHttpError(message, {
statusCode: aborted ? 504 : 502,
errorCode: aborted ? 'douyin_mashida_gateway_timeout' : 'douyin_mashida_gateway_failed',
cause: error,
context: { method: input.method },
})
} finally {
clearTimeout(timeout)
}
const parsed = parseJsonObject(responseText)
const code = String(parsed?.code ?? '').trim()
const message = String(parsed?.message ?? '').trim()
logExternalHttpPacket('[douyin-mashida/http]', '收到网关响应', {
packetId,
method: input.method,
status,
durationMs: Date.now() - startedAt,
response: { code, message, hasData: parsed ? 'data' in parsed : false },
})
if (status < 200 || status >= 300) {
throw createHttpError(`抖音码速达网关返回 HTTP ${status}${message ? `:${message}` : ''}`, {
statusCode: 502,
errorCode: 'douyin_mashida_gateway_http_error',
context: { method: input.method, status, responseText: responseText.slice(0, 500) },
})
}
if (!parsed) {
throw createHttpError('抖音码速达网关返回内容不是 JSON', {
statusCode: 502,
errorCode: 'douyin_mashida_gateway_invalid_json',
context: { method: input.method, responseText: responseText.slice(0, 500) },
})
}
if (code !== '10000') {
throw createHttpError(
`抖音码速达接口调用失败(${input.method}):${message || '未知错误'}(code=${code || '空'})`,
{
statusCode: 502,
errorCode: 'douyin_mashida_gateway_business_failed',
context: { method: input.method, code, message },
},
)
}
return { code, message, data: parsed.data, raw: parsed }
}
function parseJsonObject(text: string): JsonObject | null {
const normalized = String(text || '').trim()
if (!normalized) {
return null
}
try {
const parsed: unknown = JSON.parse(normalized)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
}
function isAbortError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'name' in error &&
(error as { name?: string }).name === 'AbortError',
)
}
/** 让调用方少写一次 asJsonObject。 */
export function responseDataAsObject(response: DouyinMashidaGatewayResponse): JsonObject {
return asJsonObject(response.data)
}
@@ -0,0 +1,88 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildDouyinMashidaMessageEventId,
extractDouyinMashidaMessageData,
parseMessageData,
} from './notify-service.js'
test('大整数精度:19 位订单号必须原样保留(JSON.parse 会四舍五入)', () => {
// 这段就是平台推送的真实形态:p_id / s_ids 是裸数字,超 JS 安全整数
const raw =
'{"pay_amount":1990,"p_id":4837328765348567001,"s_ids":[4837328765348567002,4837328765348567003]}'
// 先证明「不修」会出问题
const naive = JSON.parse(raw) as { p_id: number; s_ids: number[] }
assert.notEqual(String(naive.p_id), '4837328765348567001')
const parsed = parseMessageData(raw)
assert.equal(parsed.p_id, '4837328765348567001')
assert.deepEqual(parsed.s_ids, ['4837328765348567002', '4837328765348567003'])
// 其余字段照常解析
assert.equal(parsed.pay_amount, 1990)
})
test('大整数精度:带引号的字符串订单号不被动过', () => {
const parsed = parseMessageData('{"p_id":"4837328765348567001","order_id":"123"}')
assert.equal(parsed.p_id, '4837328765348567001')
assert.equal(parsed.order_id, '123')
})
test('大整数精度:短数字(如 shop_id)不受影响', () => {
const parsed = parseMessageData('{"shop_id":77977,"p_id":1}')
assert.equal(parsed.shop_id, 77977)
assert.equal(parsed.p_id, 1)
})
test('data 提取:body 直接是 JSON 字符串时原样返回(验签必须用原文)', () => {
const body = '{"pay_amount":1990,"p_id":1}'
assert.equal(extractDouyinMashidaMessageData(body), body)
assert.equal(extractDouyinMashidaMessageData(` ${body} `), body)
})
test('data 提取:兼容 urlencoded 的 data= 形式', () => {
const json = '{"p_id":1,"s_ids":[2]}'
const body = `data=${encodeURIComponent(json)}`
assert.equal(extractDouyinMashidaMessageData(body), json)
})
test('data 提取:空 body 返回空串', () => {
assert.equal(extractDouyinMashidaMessageData(''), '')
assert.equal(extractDouyinMashidaMessageData(undefined), '')
})
test('data 解析:坏 JSON 不抛错,返回空对象', () => {
assert.deepEqual(parseMessageData('{"p_id":1}'), { p_id: 1 })
assert.deepEqual(parseMessageData('{bad json'), {})
assert.deepEqual(parseMessageData('[1,2]'), {})
assert.deepEqual(parseMessageData(''), {})
})
test('幂等键:有订单号时按 topic:pub_time:p_id:s_ids 派生', () => {
const eventId = buildDouyinMashidaMessageEventId({
topic: 'trade_TradePaid',
pubTime: '1780000000',
data: '{"p_id":1}',
// 订单号用字符串,避免测试自己先被 JS 数字精度坑掉
message: { p_id: '4837328765348567000', s_ids: ['4837328765348567001', '4837328765348567002'] },
})
assert.equal(
eventId,
'trade_TradePaid:1780000000:4837328765348567000:4837328765348567001,4837328765348567002',
)
})
test('幂等键:拿不到订单号时退回 data 摘要(同一内容稳定、内容变则键变)', () => {
const base = { topic: 'refund_RefundSuccess', pubTime: '1780000000' }
const first = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} })
const again = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":1}', message: {} })
const changed = buildDouyinMashidaMessageEventId({ ...base, data: '{"a":2}', message: {} })
assert.equal(first, again)
assert.notEqual(first, changed)
assert.ok(first.startsWith('refund_RefundSuccess:1780000000:'))
})
@@ -0,0 +1,384 @@
import { isPlainObject, type JsonObject } from '../../../types/json.js'
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { upsertOrderFromSource } from '../../order/order-service.js'
import { invalidateCertificatesForLocalOrder } from '../../redeem/certificate-service.js'
import { createHttpError } from '../../../utils/http.js'
import { logIntegration } from '../../../utils/logger.js'
import { createHash } from 'node:crypto'
import { fetchDouyinMashidaOrderDetail } from './gateway-client.js'
import {
DOUYIN_MASHIDA_PLATFORM,
DOUYIN_MASHIDA_PROVIDER,
buildDouyinMashidaSourceEvent,
resolveDouyinMashidaParentOrderId,
} from './order-mapper.js'
import { ensureCertificateForOrderItem } from '../../redeem/certificate-service.js'
import {
getDouyinMashidaSourceConfig,
type DouyinMashidaSourceConfig,
} from './source-config-service.js'
import { verifyMessageSignature } from './sign.js'
/**
* 抖音码速达消息推送入口(验签 → 幂等 → 分派)。
*
* 与 affiliate_dash 回调同构(`services/platforms/affiliate-dash/notify-service.ts:21`):
* 1. 验签:`md5(secret + "data"+data + "pub_time"+pubTime + "topic"+topic + secret)`,
* `data` 必须是 **body 原始 JSON 字符串**(不能先 parse 再 stringify);
* 2. 幂等:`webhook_events(provider, event_id)` 唯一键去重(平台会重推);
* 3. 分派:本期只处理付款进单与退款/取消作废券码,其余 topic 只留痕。
*/
export type DouyinMashidaMessageResult = {
ok: boolean
duplicate: boolean
topic: string
action: string
eventId: string
orderId?: number | null
certificateCount?: number
}
export async function handleDouyinMashidaMessage(input: {
topic: string
pubTime: string
sign: string
rawBody: string
config?: DouyinMashidaSourceConfig | undefined
}): Promise<DouyinMashidaMessageResult> {
const config = input.config || getDouyinMashidaSourceConfig()
const topic = String(input.topic || '').trim()
const pubTime = String(input.pubTime || '').trim()
const sign = String(input.sign || '').trim()
const data = extractDouyinMashidaMessageData(input.rawBody)
if (!config.enabled) {
throw createHttpError('抖音码速达接入未启用', {
statusCode: 409,
errorCode: 'douyin_mashida_disabled',
})
}
if (!topic || !pubTime || !sign) {
throw createHttpError('抖音码速达推送缺少 topic/pub_time/sign', {
statusCode: 400,
errorCode: 'douyin_mashida_message_params_missing',
context: { topic, hasPubTime: Boolean(pubTime), hasSign: Boolean(sign) },
})
}
const verified = verifyMessageSignature({
secret: config.secret,
topic,
pubTime,
data,
sign,
})
if (!verified) {
throw createHttpError('抖音码速达推送验签失败', {
statusCode: 401,
errorCode: 'douyin_mashida_message_invalid_signature',
context: { topic, pubTime },
})
}
const message = parseMessageData(data)
const eventId = buildDouyinMashidaMessageEventId({ topic, pubTime, data, message })
const dedupe = await insertWebhookEventOnce({
provider: DOUYIN_MASHIDA_PROVIDER,
eventId,
eventType: topic,
payload: { topic, pubTime, data },
})
if (!dedupe.inserted) {
logIntegration('[douyin-mashida/notify]', '重复推送已跳过', { topic, eventId })
return { ok: true, duplicate: true, topic, action: 'duplicate', eventId }
}
if (!config.handledTopics.includes(topic)) {
logIntegration('[douyin-mashida/notify]', '未在白名单内的 topic 只留痕', { topic, eventId })
return { ok: true, duplicate: false, topic, action: 'ignored-topic', eventId }
}
if (topic === 'trade_TradePaid') {
return handlePaidMessage({ message, topic, eventId, config })
}
if (topic === 'trade_TradeCanceled' || topic === 'refund_RefundSuccess') {
return handleInvalidateMessage({ message, topic, eventId })
}
logIntegration('[douyin-mashida/notify]', 'topic 暂无处理逻辑', { topic, eventId })
return { ok: true, duplicate: false, topic, action: 'noop', eventId }
}
/** `trade_TradePaid`:拉订单详情 → 建单 → 生成券码并同步给平台。 */
async function handlePaidMessage(input: {
message: JsonObject
topic: string
eventId: string
config: DouyinMashidaSourceConfig
}): Promise<DouyinMashidaMessageResult> {
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
if (!parentOrderId) {
throw createHttpError('抖音码速达推送缺少订单号 p_id', {
statusCode: 400,
errorCode: 'douyin_mashida_order_id_missing',
context: { topic: input.topic, eventId: input.eventId },
})
}
const detail = await fetchDouyinMashidaOrderDetail({
shopOrderId: parentOrderId,
config: input.config,
})
const sourceEvent = buildDouyinMashidaSourceEvent({ message: input.message, detail })
if (sourceEvent.items.length === 0) {
// 详情里没有商品明细:多为上游时序问题,抛错让平台重推(也可人工补)
throw createHttpError('抖音码速达订单详情暂无商品明细,稍后重试', {
statusCode: 409,
errorCode: 'douyin_mashida_order_items_empty',
context: { orderId: parentOrderId },
})
}
const result = await upsertOrderFromSource(sourceEvent, { sourceLabel: 'douyin_mashida' })
const order = result.order
if (!order) {
logIntegration('[douyin-mashida/notify]', '订单未落库,跳过发券', {
orderId: parentOrderId,
ignoreReason: result.ignoreReason || '',
})
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'order-ignored',
eventId: input.eventId,
orderId: null,
}
}
// 只有真正付款的单才发券(详情可能显示已取消/退款)
if (String(order.pay_status || '') !== 'paid') {
logIntegration('[douyin-mashida/notify]', '订单非已支付状态,跳过发券', {
orderId: order.id,
payStatus: order.pay_status,
})
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'skipped-unpaid',
eventId: input.eventId,
orderId: order.id,
}
}
let certificateCount = 0
for (const orderItem of result.orderItems || []) {
await ensureCertificateForOrderItem({ order, orderItem, config: input.config })
certificateCount += 1
}
logIntegration('[douyin-mashida/notify]', '付款消息处理完成', {
topic: input.topic,
eventId: input.eventId,
orderId: order.id,
platformOrderId: order.platform_order_id,
itemCount: (result.orderItems || []).length,
certificateCount,
})
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'paid-order-upserted',
eventId: input.eventId,
orderId: order.id,
certificateCount,
}
}
/** 取消/退款:把该订单下未核销的券作废。 */
async function handleInvalidateMessage(input: {
message: JsonObject
topic: string
eventId: string
}): Promise<DouyinMashidaMessageResult> {
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
if (!parentOrderId) {
// 文档里 refund_*/trade_TradeCanceled 这几条没列字段(可能只是文档缺失)。
// 这里不抛错:抛错会让平台无意义重推,留痕后由人工按订单号处理。
logIntegration(
'[douyin-mashida/notify]',
'作废券码消息里没有订单号,已留痕待人工处理',
{ topic: input.topic, eventId: input.eventId },
{ level: 'warn' },
)
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'invalidate-order-id-missing',
eventId: input.eventId,
orderId: null,
}
}
const order = await findLatestOrderByPlatformOrderId({
provider: DOUYIN_MASHIDA_PROVIDER,
platform: DOUYIN_MASHIDA_PLATFORM,
platformOrderId: parentOrderId,
})
if (!order) {
// 本地没有这笔单(可能没订阅过付款消息):留痕即可,不让平台无意义重推
logIntegration(
'[douyin-mashida/notify]',
'作废券码时未找到本地订单',
{ topic: input.topic, eventId: input.eventId, platformOrderId: parentOrderId },
{ level: 'warn' },
)
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'invalidate-order-not-found',
eventId: input.eventId,
orderId: null,
}
}
const invalidatedCount = await invalidateCertificatesForLocalOrder({
localOrderId: order.id,
reason: input.topic,
})
return {
ok: true,
duplicate: false,
topic: input.topic,
action: 'certificates-invalidated',
eventId: input.eventId,
orderId: order.id,
certificateCount: invalidatedCount,
}
}
/**
* 从 POST body 取 `data` 原文(必须与平台签名时使用的字符串完全一致)。
* 兼容两种发法:body 直接是 JSON 字符串;或 `data=<json>` 的 urlencoded 表单。
*/
export function extractDouyinMashidaMessageData(rawBody: unknown): string {
const raw = String(rawBody ?? '')
const trimmed = raw.trim()
if (!trimmed) {
return ''
}
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed
}
try {
const params = new URLSearchParams(trimmed)
const data = params.get('data')
if (data !== null) {
return data
}
} catch {
// 落到原样返回
}
return trimmed
}
/**
* 解析推送 data,并**修正大整数精度**。
*
* ⚠️ 抖店订单号是 18~19 位数字,超过 JS 安全整数(2^53-1);平台在消息里把 `p_id`/`s_ids`
* 声明为 Integer,`JSON.parse` 会把 `4837328765348567001` 变成 `4837328765348567000` —— 拿这个
* 去查订单/回执必然错位。所以这里在解析后,从**原始 JSON 文本**里把长数字原样抓回来(存成字符串)。
*/
export function parseMessageData(data: string): JsonObject {
const normalized = String(data || '').trim()
if (!normalized) {
return {}
}
let parsed: JsonObject = {}
try {
const value: unknown = JSON.parse(normalized)
parsed = isPlainObject(value) ? value : {}
} catch {
parsed = {}
}
return applyRawBigIntegerOverrides(parsed, normalized)
}
/** JSON 里以裸数字出现、且长度可能超过安全整数的订单类字段。 */
const BIG_INTEGER_FIELDS = ['p_id', 'order_id', 's_id', 'aftersale_id', 'shop_order_id'] as const
const BIG_INTEGER_ARRAY_FIELDS = ['s_ids'] as const
export function applyRawBigIntegerOverrides(parsed: JsonObject, rawText: string): JsonObject {
const result: JsonObject = { ...parsed }
for (const field of BIG_INTEGER_FIELDS) {
// 只抓「裸数字」形式(带引号的字符串本来就精确)
const match = new RegExp(`"${field}"\\s*:\\s*(\\d{15,})`).exec(rawText)
if (match?.[1]) {
result[field] = match[1]
}
}
for (const field of BIG_INTEGER_ARRAY_FIELDS) {
const match = new RegExp(`"${field}"\\s*:\\s*\\[([^\\]]*)\\]`).exec(rawText)
if (!match) {
continue
}
const ids = String(match[1] || '')
.split(',')
.map((item) => item.trim())
.filter((item) => /^\d+$/.test(item))
if (ids.length > 0) {
result[field] = ids
}
}
return result
}
/**
* 幂等键:平台没有 `event_id`,用 `topic:pub_time:p_id:s_ids` 派生;
* 拿不到订单号时退回 `topic:pub_time:<data 的 sha256 前 16 位>`(内容级去重)。
*/
export function buildDouyinMashidaMessageEventId(input: {
topic: string
pubTime: string
data: string
message: JsonObject
}): string {
const parentOrderId = resolveDouyinMashidaParentOrderId(input.message)
const subIds = Array.isArray(input.message.s_ids)
? input.message.s_ids.map((id) => String(id || '').trim()).filter(Boolean)
: []
if (parentOrderId) {
return [input.topic, input.pubTime, parentOrderId, subIds.join(',')].join(':')
}
const digest = createHash('sha256')
.update(String(input.data || ''), 'utf8')
.digest('hex')
.slice(0, 16)
return [input.topic, input.pubTime, digest].join(':')
}
@@ -0,0 +1,155 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
DOUYIN_MASHIDA_PLATFORM,
DOUYIN_MASHIDA_PROVIDER,
buildDouyinMashidaSourceEvent,
extractDouyinMashidaSubOrderId,
mapDouyinMashidaOrderState,
parseEpochSecondsIso,
resolveDouyinMashidaParentOrderId,
resolveDouyinMashidaReceiptOrderId,
} from './order-mapper.js'
// ⚠️ 订单号一律用**字符串**:19 位抖店订单号超出 JS 安全整数(2^53-1),
// 写成数字字面量在测试里就会被四舍五入(真实链路里 JSON.parse 也会)。
const TRADE_PAID_MESSAGE = {
pay_amount: 1990,
s_ids: ['4837328765348567001'],
order_status: 2,
order_type: 0,
pay_time: 1630373504,
p_id: '4837328765348567001',
shop_id: 77977,
pay_type: 1,
biz: 2,
}
const ORDER_DETAIL = {
order_id: '4837328765348567001',
order_status: 105,
shop_id: 77977,
shop_name: '刘好好严选',
order_amount: 1990,
pay_amount: 1990,
pay_time: 1630373504,
create_time: 1630373500,
finish_time: 0,
doudian_open_id: '1@#SGf7u/L4T728WdIaNcm6c+7SOqTObqbPh6wJBSm6lRGO+',
post_tel: '13800000000',
post_receiver: '张三',
buyer_words: '充值账号 123456',
seller_words: '注意核对',
order_type: 5,
biz: 11,
sku_order_list: [
{
order_id: '4837328765348567002',
// 商品 ID 也是 19 位:写成数字字面量同样会被 lint 拦(no-loss-of-precision)
product_id: '3473196049974326153',
sku_id: 3254535,
out_product_id: '432655662343',
out_sku_id: '43564553',
product_name: '暗潮1钻 代练',
goods_price: 1990,
item_num: 2,
order_amount: 1990,
pay_amount: 1990,
code: 'DY-SKU-001',
},
],
}
test('抖店状态映射:内部只有 4 个状态,completed 降级为 paid', () => {
assert.deepEqual(mapDouyinMashidaOrderState(1), { orderStatus: 'created', payStatus: 'unpaid' })
assert.deepEqual(mapDouyinMashidaOrderState('105'), { orderStatus: 'paid', payStatus: 'paid' })
assert.deepEqual(mapDouyinMashidaOrderState(2), { orderStatus: 'paid', payStatus: 'paid' })
assert.deepEqual(mapDouyinMashidaOrderState(101), { orderStatus: 'paid', payStatus: 'paid' })
assert.deepEqual(mapDouyinMashidaOrderState(3), { orderStatus: 'paid', payStatus: 'paid' })
// 5=已完成:内部优先级表不认识 completed,按方案 D3 降级
assert.deepEqual(mapDouyinMashidaOrderState(5), { orderStatus: 'paid', payStatus: 'paid' })
assert.deepEqual(mapDouyinMashidaOrderState(4), { orderStatus: 'closed', payStatus: 'unpaid' })
// 未知状态按「已支付」兜底(消息本身是付款事件)
assert.deepEqual(mapDouyinMashidaOrderState(undefined), {
orderStatus: 'paid',
payStatus: 'paid',
})
})
test('订单号口径:platformOrderId 用父单 p_id,子单号进 item 快照', () => {
const event = buildDouyinMashidaSourceEvent({
message: TRADE_PAID_MESSAGE,
detail: ORDER_DETAIL,
})
assert.equal(event.provider, DOUYIN_MASHIDA_PROVIDER)
assert.equal(event.platform, DOUYIN_MASHIDA_PLATFORM)
assert.equal(event.platformOrderId, '4837328765348567001')
assert.equal(event.shopId, '77977')
assert.equal(event.shopName, '刘好好严选')
assert.equal(event.totalAmount, 1990)
assert.equal(event.orderStatus, 'paid')
assert.equal(event.payStatus, 'paid')
assert.equal(event.paidAt, new Date(1630373504 * 1000).toISOString())
assert.equal(event.receiverContact, '13800000000')
assert.equal(event.buyerName, '张三')
assert.equal(event.items.length, 1)
const [item] = event.items as Array<Record<string, unknown>>
assert.equal(item.itemId, '4837328765348567002')
assert.equal(item.skuCode, 'DY-SKU-001')
assert.equal(item.skuName, '暗潮1钻 代练')
assert.equal(item.quantity, 2)
const snapshot = item.snapshot as Record<string, unknown>
assert.equal(snapshot.douyinSubOrderId, '4837328765348567002')
// 买家信息只留在订单侧快照里(不进打手工单素材,由既有隐私红线保证)
assert.equal(snapshot.buyerWords, '充值账号 123456')
})
test('回执订单号:子单号优先、父单兜底', () => {
assert.equal(
resolveDouyinMashidaReceiptOrderId({
parentOrderId: '4837328765348567000',
subOrderId: '4837328765348567001',
}),
'4837328765348567001',
)
assert.equal(
resolveDouyinMashidaReceiptOrderId({ parentOrderId: '4837328765348567000' }),
'4837328765348567000',
)
})
test('从 item 快照里取子订单号(快照可能是 JSON 字符串)', () => {
assert.equal(extractDouyinMashidaSubOrderId({ douyinSubOrderId: 'S1' }), 'S1')
assert.equal(extractDouyinMashidaSubOrderId('{"douyinSubOrderId":"S2"}'), 'S2')
assert.equal(extractDouyinMashidaSubOrderId('not-json'), '')
assert.equal(extractDouyinMashidaSubOrderId(null), '')
})
test('详情缺商品明细时 items 为空(交给上层重试,不造空单)', () => {
const event = buildDouyinMashidaSourceEvent({
message: TRADE_PAID_MESSAGE,
detail: { ...ORDER_DETAIL, sku_order_list: [] },
})
assert.deepEqual(event.items, [])
})
test('父订单号取 p_id,缺失时回退详情的 order_id', () => {
assert.equal(
resolveDouyinMashidaParentOrderId(TRADE_PAID_MESSAGE, ORDER_DETAIL),
'4837328765348567001',
)
assert.equal(resolveDouyinMashidaParentOrderId({}, { order_id: 'D1' }), 'D1')
assert.equal(resolveDouyinMashidaParentOrderId({}, {}), '')
})
test('秒级时间戳转 ISO:非法值返回空串', () => {
assert.equal(parseEpochSecondsIso(1630373504), new Date(1630373504 * 1000).toISOString())
assert.equal(parseEpochSecondsIso(0), '')
assert.equal(parseEpochSecondsIso('abc'), '')
assert.equal(parseEpochSecondsIso(null), '')
})
@@ -0,0 +1,214 @@
import { isPlainObject, type JsonObject } from '../../../types/json.js'
import type { SourceOrderEvent } from '../../order/order-service.js'
import { nowIso } from '../../../utils/time.js'
import { sanitizeWellFormedUnicode } from '../../../utils/json.js'
/**
* 抖音码速达 → order_site 统一进单模型(SourceOrderEvent)。
*
* 关键事实(见 docs/抖音-码速达/05-最小接入方案分析.md §2.1):
* `trade_TradePaid` 消息里**只有** p_id / s_ids / pay_amount / pay_time 等,没有商品与买家账号,
* 必须再用 `order.orderDetail` 补齐商品明细、买家留言、收件人信息。
*
* 订单号口径:`platformOrderId` 落**父订单号 p_id**;子订单号(sku_order_list[].order_id)
* 放进 item 的 snapshot,回执(coupons.syncV2 / order.message)时优先用它、父单兜底。
*/
export const DOUYIN_MASHIDA_PROVIDER = 'douyin_mashida'
export const DOUYIN_MASHIDA_PLATFORM = 'doudian'
/** 内部订单状态只有 4 个值(order-service.ts:432)——抖店状态按「多对一」降级。 */
export const DOUYIN_MASHIDA_ORDER_STATUS_MAP: Record<
string,
{ orderStatus: string; payStatus: string }
> = {
// 待确认/待支付
'1': { orderStatus: 'created', payStatus: 'unpaid' },
// 已支付 / 备货中 / 部分发货 / 已发货 / 已完成 → 一律 paid
// (内部优先级表不认识 completed/shipped,降级避免状态合并时出现未知值,见方案 D3)
'105': { orderStatus: 'paid', payStatus: 'paid' },
'2': { orderStatus: 'paid', payStatus: 'paid' },
'101': { orderStatus: 'paid', payStatus: 'paid' },
'3': { orderStatus: 'paid', payStatus: 'paid' },
'5': { orderStatus: 'paid', payStatus: 'paid' },
// 已取消
'4': { orderStatus: 'closed', payStatus: 'unpaid' },
}
export function mapDouyinMashidaOrderState(rawStatus: unknown): {
orderStatus: string
payStatus: string
} {
const key = String(rawStatus ?? '').trim()
return DOUYIN_MASHIDA_ORDER_STATUS_MAP[key] || { orderStatus: 'paid', payStatus: 'paid' }
}
export function resolveDouyinMashidaParentOrderId(
message: JsonObject = {},
detail: JsonObject = {},
): string {
return pickFirstNonEmpty([message.p_id, detail.order_id, message.order_id])
}
export function listDouyinMashidaSubOrderIds(detail: JsonObject = {}): string[] {
const rows = Array.isArray(detail.sku_order_list) ? detail.sku_order_list : []
const ids = rows
.map((row) => (isPlainObject(row) ? pickFirstNonEmpty([row.order_id]) : ''))
.filter(Boolean)
return [...new Set(ids)]
}
/**
* 回执用的订单号:子订单号优先、父单兜底。
* (平台方说业务上一般没有子订单,但 syncV2/order.message 的字段定义是「子订单ID」,不能想当然。)
*/
export function resolveDouyinMashidaReceiptOrderId(input: {
parentOrderId: string
subOrderId?: string | undefined
}): string {
return pickFirstNonEmpty([input.subOrderId, input.parentOrderId])
}
/** 从 order_items.item_snapshot_json 里取回子订单号(发券/回执时用)。 */
export function extractDouyinMashidaSubOrderId(snapshot: unknown): string {
let parsed: unknown = snapshot
if (typeof snapshot === 'string') {
try {
parsed = JSON.parse(snapshot)
} catch {
return ''
}
}
if (!isPlainObject(parsed)) {
return ''
}
return pickFirstNonEmpty([parsed.douyinSubOrderId])
}
export function buildDouyinMashidaSourceEvent(input: {
message: JsonObject
detail: JsonObject
receivedAt?: string | undefined
}): SourceOrderEvent {
const message = input.message || {}
const detail = input.detail || {}
const parentOrderId = resolveDouyinMashidaParentOrderId(message, detail)
const state = mapDouyinMashidaOrderState(detail.order_status ?? message.order_status)
const paidAt = parseEpochSecondsIso(detail.pay_time ?? message.pay_time)
const receivedAt = input.receivedAt || nowIso()
return {
provider: DOUYIN_MASHIDA_PROVIDER,
platform: DOUYIN_MASHIDA_PLATFORM,
shopId: pickFirstNonEmpty([detail.shop_id, message.shop_id]),
shopName: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.shop_name])),
platformOrderId: parentOrderId,
orderStatus: state.orderStatus,
payStatus: state.payStatus,
buyerId: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.doudian_open_id])),
buyerName: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.post_receiver])),
receiverContact: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.post_tel])),
totalAmount: normalizeFen(detail.pay_amount ?? message.pay_amount ?? detail.order_amount),
currency: 'CNY',
paidAt: paidAt || receivedAt,
rawPayload: {
source: DOUYIN_MASHIDA_PROVIDER,
receivedAt,
message,
detail,
},
items: buildDouyinMashidaItems({ message, detail, parentOrderId }),
}
}
/** 出网 `order.orderDetail` 回来后,把「消息 + 详情」拼成一件一件商品。 */
export function buildDouyinMashidaItems(input: {
message: JsonObject
detail: JsonObject
parentOrderId: string
}): Array<Record<string, unknown>> {
const detail = input.detail || {}
const message = input.message || {}
const rows = Array.isArray(detail.sku_order_list) ? detail.sku_order_list : []
const usable = rows.filter(isPlainObject)
// 详情里没有商品明细时不要造空单:让上层把订单留在「待配置」,等人工补。
if (usable.length === 0) {
return []
}
return usable.map((row) => {
const subOrderId = pickFirstNonEmpty([row.order_id])
const skuCode = pickFirstNonEmpty([row.code, row.out_sku_id, row.product_id])
const skuName = pickFirstNonEmpty([row.product_name, row.out_product_id, skuCode])
const quantity = Math.max(1, Number(row.item_num || 1) || 1)
return {
itemId: subOrderId,
externalItemId: subOrderId,
externalSkuCode: skuCode,
externalSkuName: skuName,
skuCode,
skuName,
quantity,
spec: {
source: DOUYIN_MASHIDA_PROVIDER,
parentOrderId: input.parentOrderId,
subOrderId,
productId: pickFirstNonEmpty([row.product_id]),
skuId: pickFirstNonEmpty([row.sku_id]),
outProductId: pickFirstNonEmpty([row.out_product_id]),
outSkuId: pickFirstNonEmpty([row.out_sku_id]),
code: pickFirstNonEmpty([row.code]),
itemNum: quantity,
},
snapshot: {
source: DOUYIN_MASHIDA_PROVIDER,
parentOrderId: input.parentOrderId,
// 回执(syncV2 / order.message)优先用它
douyinSubOrderId: subOrderId,
subOrderId,
productId: pickFirstNonEmpty([row.product_id]),
skuId: pickFirstNonEmpty([row.sku_id]),
outProductId: pickFirstNonEmpty([row.out_product_id]),
outSkuId: pickFirstNonEmpty([row.out_sku_id]),
code: pickFirstNonEmpty([row.code]),
productName: pickFirstNonEmpty([row.product_name]),
goodsPrice: normalizeFen(row.goods_price),
orderAmount: normalizeFen(row.order_amount),
payAmount: normalizeFen(row.pay_amount),
// 买家信息只留在订单侧,不进打手工单素材(仓库既有隐私红线)
buyerWords: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.buyer_words])),
sellerWords: sanitizeWellFormedUnicode(pickFirstNonEmpty([detail.seller_words])),
shopId: pickFirstNonEmpty([detail.shop_id, message.shop_id]),
rawMessage: message,
rawSku: row,
},
}
})
}
/** 秒级时间戳 → ISO;非法值返回空串(由调用方决定兜底)。 */
export function parseEpochSecondsIso(value: unknown): string {
const seconds = Number(value)
if (!Number.isFinite(seconds) || seconds <= 0) {
return ''
}
return new Date(seconds * 1000).toISOString()
}
function normalizeFen(value: unknown): number {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? Math.round(amount) : 0
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
const normalized = String(value ?? '').trim()
if (normalized && normalized !== '0') {
return normalized
}
}
return ''
}
@@ -0,0 +1,164 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildGatewayRequestParams,
buildGatewaySignSource,
buildMessageSignSource,
signGatewayPayload,
signMessagePayload,
stringifySignValue,
verifyMessageSignature,
} from './sign.js'
// 两条 golden 向量直接取自 docs/抖音-码速达 的示例(02-接口调用规范与签名.md / 消息推送/01-买家已付款.md):
// 占位秘钥 XXXXXXXXXXXXXXXXXXXX、占位商户号 111111111、时间戳 1780000000。
test('出网签名:订单详情 golden 向量(字典序拼接 + 小写 MD5)', () => {
const origin = buildGatewaySignSource(
{
uid: '111111111',
method: 'order.orderDetail',
timestamp: '1780000000',
shop_order_id: '6917533224570258991',
},
'XXXXXXXXXXXXXXXXXXXX',
)
assert.equal(
origin,
'XXXXXXXXXXXXXXXXXXXXmethodorder.orderDetailshop_order_id6917533224570258991timestamp1780000000uid111111111XXXXXXXXXXXXXXXXXXXX',
)
assert.equal(
signGatewayPayload(
{
uid: '111111111',
method: 'order.orderDetail',
timestamp: '1780000000',
shop_order_id: '6917533224570258991',
},
'XXXXXXXXXXXXXXXXXXXX',
),
'e4a08602d21966e99d2fa0921d7456b0',
)
})
test('出网签名:sign 本身不参与原串,键顺序不影响结果', () => {
const base = { uid: '1', method: 'topup.result', timestamp: '1700000000' }
const withSign = { ...base, sign: 'deadbeef' }
assert.equal(signGatewayPayload(base, 'sk'), signGatewayPayload(withSign, 'sk'))
assert.equal(
signGatewayPayload({ b: '2', a: '1', uid: '1' }, 'sk'),
signGatewayPayload({ uid: '1', a: '1', b: '2' }, 'sk'),
)
})
test('出网签名:参数值连接时没有 = 和 & 分隔符(与 91 卡券不同)', () => {
const origin = buildGatewaySignSource({ uid: '111111111', method: 'm' }, 'S')
assert.ok(!origin.includes('='))
assert.ok(!origin.includes('&'))
assert.equal(origin, 'Smethodmuid111111111S')
})
test('buildGatewayRequestParams:补齐公共参数 + sign,且全部为字符串', () => {
const params = buildGatewayRequestParams({
uid: '111111111',
method: 'order.orderDetail',
secret: 'XXXXXXXXXXXXXXXXXXXX',
timestamp: '1780000000',
params: { shop_order_id: '6917533224570258991' },
})
assert.deepEqual(Object.keys(params).sort(), [
'method',
'shop_order_id',
'sign',
'timestamp',
'uid',
])
assert.equal(params.sign, 'e4a08602d21966e99d2fa0921d7456b0')
assert.equal(params.shop_order_id, '6917533224570258991')
for (const value of Object.values(params)) {
assert.equal(typeof value, 'string')
}
})
test('buildGatewayRequestParams:对象参数按 JSON 字符串参与签名', () => {
const certList = [{ cert_no: 'DYC001', cert_link: 'https://x.com/redeem' }]
const params = buildGatewayRequestParams({
uid: '1',
method: 'coupons.syncV2',
secret: 'S',
timestamp: '1700000000',
params: { order_id: '9', cert_list: certList },
})
assert.equal(params.cert_list, JSON.stringify(certList))
assert.equal(
params.sign,
signGatewayPayload(
{
uid: '1',
method: 'coupons.syncV2',
timestamp: '1700000000',
order_id: '9',
cert_list: JSON.stringify(certList),
},
'S',
),
)
})
test('入站验签:TradePaid golden 向量', () => {
const data =
'{"pay_amount":1990,"s_ids":[123],"order_status":2,"order_type":0,"pay_time":1630373504,"p_id":4837328765348567000,"shop_id":77977,"pay_type":1,"biz":2}'
const origin = buildMessageSignSource({
secret: 'XXXXXXXXXXXXXXXXXXXX',
topic: 'trade_TradePaid',
pubTime: '1780000000',
data,
})
assert.ok(origin.startsWith('XXXXXXXXXXXXXXXXXXXXdata{"pay_amount":1990'))
assert.ok(origin.endsWith('topictrade_TradePaidXXXXXXXXXXXXXXXXXXXX'))
assert.equal(
signMessagePayload({
secret: 'XXXXXXXXXXXXXXXXXXXX',
topic: 'trade_TradePaid',
pubTime: '1780000000',
data,
}),
'1f4e1e6fc7e2ebad3368abfc2e07f2d1',
)
})
test('入站验签:签名正确才通过,大小写不敏感,被篡改即失败', () => {
const input = {
secret: 'sk_test',
topic: 'trade_TradePaid',
pubTime: '1780000000',
data: '{"p_id":1}',
}
const sign = signMessagePayload(input)
assert.equal(verifyMessageSignature({ ...input, sign }), true)
assert.equal(verifyMessageSignature({ ...input, sign: sign.toUpperCase() }), true)
assert.equal(verifyMessageSignature({ ...input, sign }), true)
assert.equal(verifyMessageSignature({ ...input, data: '{"p_id":2}', sign }), false)
assert.equal(verifyMessageSignature({ ...input, pubTime: '1780000001', sign }), false)
assert.equal(verifyMessageSignature({ ...input, topic: 'refund_RefundCreated', sign }), false)
assert.equal(verifyMessageSignature({ ...input, secret: 'sk_other', sign }), false)
assert.equal(verifyMessageSignature({ ...input, sign: '' }), false)
})
test('stringifySignValue:空值/数字/对象各自的处理', () => {
assert.equal(stringifySignValue(null), '')
assert.equal(stringifySignValue(undefined), '')
assert.equal(stringifySignValue(0), '0')
assert.equal(stringifySignValue(false), 'false')
assert.equal(stringifySignValue(' 1 '), ' 1 ')
assert.equal(stringifySignValue({ a: 1 }), '{"a":1}')
})
@@ -0,0 +1,130 @@
import crypto from 'node:crypto'
/**
* 抖音码速达开放平台签名(**两套**,均为 MD5 32 位小写)。
*
* 1)出网调网关(`/api/openplatform/gateway`):
* sign = md5( secret + Σ(参数名 + 参数值,按参数名字典序) + secret )
* 除 `sign` 外的**全部参数**参与(公共参数 uid/method/timestamp + 业务参数),
* 参数名与参数值直接首尾相连(没有 `=` 和 `&`)。
*
* 2)入站收消息推送:
* sign = md5( secret + "data" + data + "pub_time" + pub_time + "topic" + topic + secret )
* 只按 data/pub_time/topic 三个参数(恰好是字典序)拼接,`data` 用 POST body 里的**原始 JSON 字符串**。
*
* ⚠️ 与仓库里已有实现都**不通用**,不要试图复用:
* - 91 卡券是 `secret + k=v&… + secret` 且 **MD5 大写**(services/open-91/crypto.ts:8-30);
* - affiliate_dash 是 HMAC-SHA256(services/platforms/affiliate-dash/sign.ts:22)。
*
* 依据:docs/抖音-码速达/02-接口调用规范与签名.md、03-消息推送规范与签名.md
* (本文件的两条 golden 向量就取自这两篇文档的示例)。
*/
export const DOUYIN_MASHIDA_GATEWAY_PATH = '/api/openplatform/gateway'
export type GatewaySignInput = {
secret: string
/** 业务参数(不含 uid/method/timestamp/sign,由 buildGatewayRequestParams 统一补) */
params?: Record<string, unknown> | undefined
uid?: string | undefined
method?: string | undefined
timestamp?: string | undefined
}
/** 参数值参与签名时的字符串化规则:空值 → 空串;对象/数组 → JSON;其余走 String()。 */
export function stringifySignValue(value: unknown): string {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
/** 出网签名原串(含 secret 首尾包裹),便于日志与单测核对。 */
export function buildGatewaySignSource(params: Record<string, unknown>, secret: string): string {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign')
.map(([key, value]) => [key, stringifySignValue(value)] as const)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
return `${secret}${entries.map(([key, value]) => `${key}${value}`).join('')}${secret}`
}
export function signGatewayPayload(params: Record<string, unknown>, secret: string): string {
return md5Lower(buildGatewaySignSource(params, secret))
}
/**
* 组一次网关请求的完整参数(公共参数 + 业务参数 + sign)。
* 返回值里的键值**全部为字符串**,可直接丢给 URLSearchParams 提交。
*/
export function buildGatewayRequestParams(input: GatewaySignInput): Record<string, string> {
const uid = String(input.uid || '').trim()
const method = String(input.method || '').trim()
const timestamp = String(input.timestamp || '').trim() || String(Math.floor(Date.now() / 1000))
const business = input.params || {}
const unsigned: Record<string, unknown> = { uid, method, timestamp, ...business }
const sign = signGatewayPayload(unsigned, String(input.secret || ''))
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(unsigned)) {
result[key] = stringifySignValue(value)
}
result.sign = sign
return result
}
/** 入站推送签名原串。`data` 必须是 body 原始 JSON 字符串(不要先 JSON.parse 再 stringify)。 */
export function buildMessageSignSource(input: {
secret: string
topic: string
pubTime: string
data: string
}): string {
const secret = String(input.secret || '')
return `${secret}data${input.data}pub_time${input.pubTime}topic${input.topic}${secret}`
}
export function signMessagePayload(input: {
secret: string
topic: string
pubTime: string
data: string
}): string {
return md5Lower(buildMessageSignSource(input))
}
/** 验签(常数时间比较,大小写不敏感)。 */
export function verifyMessageSignature(input: {
secret: string
topic: string
pubTime: string
data: string
sign: string
}): boolean {
const expected = signMessagePayload(input)
return timingSafeEqualString(
expected,
String(input.sign || '')
.trim()
.toLowerCase(),
)
}
export function md5Lower(content: string): string {
return crypto.createHash('md5').update(content, 'utf8').digest('hex').toLowerCase()
}
export function timingSafeEqualString(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left, 'utf8')
const rightBuffer = Buffer.from(right, 'utf8')
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer)
}
@@ -0,0 +1,154 @@
import type { JsonObject } from '../../../types/json.js'
import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js'
import { createHttpError } from '../../../utils/http.js'
import {
hasAppConfigEntry,
readAppConfigEntry,
saveAppConfigEntry,
} from '../../config/app-config-store.js'
/**
* 抖音码速达(抖店中转开放平台)接入配置。
*
* 存放位置:`app_config_entries.douyin_mashida`(后台可改,出参必须脱敏 —— 见
* admin 配置服务里的 `secret: '' + secretMasked + hasSecret` 约定)。
*
* 说明:
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
*/
const DOUYIN_MASHIDA_CONFIG_KEY = APP_CONFIG_KEYS.douyinMashida
export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanmahui.com'
/** 最小接入只处理这几条:付款进单、取消/退款作废券码。 */
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
'trade_TradePaid',
'trade_TradeCanceled',
'refund_RefundSuccess',
] as const
export type DouyinMashidaSourceConfig = {
enabled: boolean
baseUrl: string
uid: string
secret: string
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem */
certLink: string
/** order.message 的 message_type:1-订单状态 2-订单发货 4-图片 5-视频 */
messageType: number
/** 兑换成功后发给买家的「去使用链接」(卡密页/网盘等),留空则只发文案 */
afterVerifyLink: string
/** 兑换成功后 message.content 的正文,留空用默认文案 */
afterVerifyMessage: string
timeoutMs: number
handledTopics: string[]
}
export function hasDouyinMashidaConfig(): boolean {
return hasAppConfigEntry(DOUYIN_MASHIDA_CONFIG_KEY)
}
export function getDouyinMashidaSourceConfig(): DouyinMashidaSourceConfig {
return readAppConfigEntry({
configKey: DOUYIN_MASHIDA_CONFIG_KEY,
fallback: createDefaultDouyinMashidaSourceConfig,
normalize: normalizeDouyinMashidaSourceConfig,
})
}
export function saveDouyinMashidaSourceConfig(
rawValue: unknown,
): Promise<DouyinMashidaSourceConfig> {
return saveAppConfigEntry({
configKey: DOUYIN_MASHIDA_CONFIG_KEY,
value: rawValue,
normalize: normalizeDouyinMashidaSourceConfig,
})
}
/** 出网调用前取配置:未启用/缺 uid/缺 secret 直接报错,避免拿空凭据打平台。 */
export function assertDouyinMashidaConfig(
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
): DouyinMashidaSourceConfig {
if (!config.enabled) {
throw createHttpError('抖音码速达接入未启用', {
statusCode: 409,
errorCode: 'douyin_mashida_disabled',
})
}
if (!config.uid || !config.secret) {
throw createHttpError('抖音码速达缺少 uid 或用户秘钥,请在后台平台配置里补充', {
statusCode: 409,
errorCode: 'douyin_mashida_credentials_missing',
})
}
return config
}
export function normalizeDouyinMashidaSourceConfig(rawValue: unknown): DouyinMashidaSourceConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
return {
enabled: source.enabled !== false,
baseUrl:
String(source.baseUrl || '')
.trim()
.replace(/\/+$/, '') || DOUYIN_MASHIDA_DEFAULT_BASE_URL,
uid: String(source.uid || '').trim(),
secret: String(source.secret || '').trim(),
certLink: String(source.certLink || '')
.trim()
.replace(/\/+$/, ''),
messageType: normalizeMessageType(source.messageType),
afterVerifyLink: String(source.afterVerifyLink || '').trim(),
afterVerifyMessage: String(source.afterVerifyMessage || '').trim(),
timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000),
handledTopics: normalizeHandledTopics(source.handledTopics),
}
}
export function createDefaultDouyinMashidaSourceConfig(): DouyinMashidaSourceConfig {
return {
enabled: true,
baseUrl: DOUYIN_MASHIDA_DEFAULT_BASE_URL,
uid: '',
secret: '',
certLink: '',
messageType: 2,
afterVerifyLink: '',
afterVerifyMessage: DEFAULT_AFTER_VERIFY_MESSAGE,
timeoutMs: 10000,
handledTopics: [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS],
}
}
function normalizeMessageType(value: unknown): number {
const parsed = Number(value)
return [1, 2, 4, 5].includes(parsed) ? parsed : 2
}
function normalizeHandledTopics(value: unknown): string[] {
if (!Array.isArray(value)) {
return [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS]
}
const topics = [...new Set(value.map((item) => String(item || '').trim()).filter(Boolean))]
return topics.length > 0 ? topics : [...DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS]
}
function normalizePositiveInteger(value: unknown, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -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()
}