From 72606796b3edd362e9d9748bec53119138889ea7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 9 Oct 2026 12:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyin-mashida):=20=E6=8A=96=E9=9F=B3?= =?UTF-8?q?=E7=A0=81=E9=80=9F=E8=BE=BE=E6=9C=80=E5=B0=8F=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88=E6=B6=88=E6=81=AF=E5=BB=BA=E5=8D=95?= =?UTF-8?q?/=E5=88=B8=E7=A0=81=E5=90=8C=E6=AD=A5/=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E6=A0=B8=E9=94=80/=E9=80=80=E6=AC=BE=E4=BD=9C=E5=BA=9F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 签名:出网 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 原始字符串 --- apps/backend/src/app.ts | 8 + apps/backend/src/config/app-config-keys.ts | 2 + .../db/migrations/094_redeem_certificates.sql | 57 +++ .../repositories/redeem-certificate-repo.ts | 233 +++++++++++ .../src/routes/admin/platform-config.ts | 3 + .../admin/platform-config/douyin-mashida.ts | 49 +++ apps/backend/src/routes/douyin-mashida.ts | 67 +++ apps/backend/src/routes/redeem.ts | 43 ++ .../platform-config/douyin-mashida-service.ts | 75 ++++ .../douyin-mashida/gateway-client.ts | 109 +++++ .../platforms/douyin-mashida/http-client.ts | 165 ++++++++ .../douyin-mashida/notify-service.test.ts | 88 ++++ .../douyin-mashida/notify-service.ts | 384 ++++++++++++++++++ .../douyin-mashida/order-mapper.test.ts | 155 +++++++ .../platforms/douyin-mashida/order-mapper.ts | 214 ++++++++++ .../platforms/douyin-mashida/sign.test.ts | 164 ++++++++ .../services/platforms/douyin-mashida/sign.ts | 130 ++++++ .../douyin-mashida/source-config-service.ts | 154 +++++++ .../services/redeem/certificate-service.ts | 339 ++++++++++++++++ .../services/redeem/redeem-service.test.ts | 69 ++++ .../src/services/redeem/redeem-service.ts | 298 ++++++++++++++ 21 files changed, 2806 insertions(+) create mode 100644 apps/backend/src/db/migrations/094_redeem_certificates.sql create mode 100644 apps/backend/src/repositories/redeem-certificate-repo.ts create mode 100644 apps/backend/src/routes/admin/platform-config/douyin-mashida.ts create mode 100644 apps/backend/src/routes/douyin-mashida.ts create mode 100644 apps/backend/src/routes/redeem.ts create mode 100644 apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/http-client.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/notify-service.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/order-mapper.test.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/sign.test.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/sign.ts create mode 100644 apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts create mode 100644 apps/backend/src/services/redeem/certificate-service.ts create mode 100644 apps/backend/src/services/redeem/redeem-service.test.ts create mode 100644 apps/backend/src/services/redeem/redeem-service.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 22070a03..82f2a5b5 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -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) diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index d3204884..9be4b937 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -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 diff --git a/apps/backend/src/db/migrations/094_redeem_certificates.sql b/apps/backend/src/db/migrations/094_redeem_certificates.sql new file mode 100644 index 00000000..d7e180e0 --- /dev/null +++ b/apps/backend/src/db/migrations/094_redeem_certificates.sql @@ -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,发出去就收不回来,换域名只影响新券)'; diff --git a/apps/backend/src/repositories/redeem-certificate-repo.ts b/apps/backend/src/repositories/redeem-certificate-repo.ts new file mode 100644 index 00000000..16c4206e --- /dev/null +++ b/apps/backend/src/repositories/redeem-certificate-repo.ts @@ -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 { + const result = await query( + `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 { + const result = await query( + `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 { + const result = await query( + `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 { + for (let attempt = 1; attempt <= CERT_CODE_MAX_GENERATE_ATTEMPTS; attempt += 1) { + const certNo = generateRedeemCertificateCode() + + try { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 +} diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index ab2e648c..16e40be1 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -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 diff --git a/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts new file mode 100644 index 00000000..0f0705d4 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/douyin-mashida.ts @@ -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 diff --git a/apps/backend/src/routes/douyin-mashida.ts b/apps/backend/src/routes/douyin-mashida.ts new file mode 100644 index 00000000..a7f48b5c --- /dev/null +++ b/apps/backend/src/routes/douyin-mashida.ts @@ -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 diff --git a/apps/backend/src/routes/redeem.ts b/apps/backend/src/routes/redeem.ts new file mode 100644 index 00000000..8721f452 --- /dev/null +++ b/apps/backend/src/routes/redeem.ts @@ -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 diff --git a/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts new file mode 100644 index 00000000..f0f8c1ad --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/douyin-mashida-service.ts @@ -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 +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts new file mode 100644 index 00000000..d7aeedc0 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/gateway-client.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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]' +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/http-client.ts b/apps/backend/src/services/platforms/douyin-mashida/http-client.ts new file mode 100644 index 00000000..47218d66 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/http-client.ts @@ -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 | undefined + config?: DouyinMashidaSourceConfig | undefined + timeoutMs?: number | undefined +}): Promise { + 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) +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts new file mode 100644 index 00000000..91a812d9 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.test.ts @@ -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:')) +}) diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts new file mode 100644 index 00000000..b6ff13b1 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -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 { + 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 { + 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 { + 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=` 的 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:`(内容级去重)。 + */ +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(':') +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/order-mapper.test.ts b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.test.ts new file mode 100644 index 00000000..607e63fc --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.test.ts @@ -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> + 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 + 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), '') +}) diff --git a/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts new file mode 100644 index 00000000..395eeb16 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/order-mapper.ts @@ -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> { + 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 '' +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/sign.test.ts b/apps/backend/src/services/platforms/douyin-mashida/sign.test.ts new file mode 100644 index 00000000..4165c8b5 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/sign.test.ts @@ -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}') +}) diff --git a/apps/backend/src/services/platforms/douyin-mashida/sign.ts b/apps/backend/src/services/platforms/douyin-mashida/sign.ts new file mode 100644 index 00000000..354a7ea3 --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/sign.ts @@ -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 | 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, 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, secret: string): string { + return md5Lower(buildGatewaySignSource(params, secret)) +} + +/** + * 组一次网关请求的完整参数(公共参数 + 业务参数 + sign)。 + * 返回值里的键值**全部为字符串**,可直接丢给 URLSearchParams 提交。 + */ +export function buildGatewayRequestParams(input: GatewaySignInput): Record { + 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 = { uid, method, timestamp, ...business } + const sign = signGatewayPayload(unsigned, String(input.secret || '')) + + const result: Record = {} + 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) +} diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts new file mode 100644 index 00000000..45842b9a --- /dev/null +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -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 { + 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]' +} diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts new file mode 100644 index 00000000..3087a12d --- /dev/null +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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')}` +} diff --git a/apps/backend/src/services/redeem/redeem-service.test.ts b/apps/backend/src/services/redeem/redeem-service.test.ts new file mode 100644 index 00000000..26f7096f --- /dev/null +++ b/apps/backend/src/services/redeem/redeem-service.test.ts @@ -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', + ) +}) diff --git a/apps/backend/src/services/redeem/redeem-service.ts b/apps/backend/src/services/redeem/redeem-service.ts new file mode 100644 index 00000000..e987f96f --- /dev/null +++ b/apps/backend/src/services/redeem/redeem-service.ts @@ -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 { + const orderNo = normalizeOrderNo(input.orderNo) + const context = await resolveRedeemContext(orderNo) + + return buildView(orderNo, context) +} + +/** 确认兑换:核销上报(幂等),成功后回显券码。 */ +export async function confirmRedeemOrder(input: { orderNo: string }): Promise { + 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 { + 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() +}