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