抖音码速达的皮肤类商品改为「自动发货 + 领取链接」:买家不用输订单号,点链接直接领。
**分流(按内部 SKU)**
- 平台闸门放行 `provider='douyin_mashida' && platform='doudian'`,走**独立分支**
(原有 91/快手路径一行未动,避免外溢)
- 判据:内部 SKU(`sku_order_list[].code`,即 order_items.sku_code)命中权威表
`AFFILIATE_DASH_SKU_MAPPING` → 新执行器 `douyin_mashida_skin`;未命中 → `manual_dispatch`
- 皮肤判定与商品图同源(都用那张权威表),保证「能路由 ⇒ 有图」
- 履约路由新增 `internal_sku` 匹配维度(`rules[].internalSku`,NFKC+去空白+小写,exact/contains);
旧规则缺 `matchField` 时默认 `product_name`,**归一化/优先级/回退顺序完全不变**
**执行器与领取 token**
- 新增 `douyin-mashida-skin-executor`:建 claim token、置 `link_generated/pending_claim`,
`resolveDeliveryLink` 返回本站路径式领取链接;注册进 registry、planner 分支、
bootstrap 种子 profile(`抖音皮肤自动发货`);进 `ROUTABLE_EXECUTOR_KEYS`
- token 粒度 = 一个履约单元一个(`claim_tokens.task_id` 唯一);一单多项时
cert_link/买家消息取 task id 最小的那个并告警
**cert_link 与发消息**
- 皮肤订单且该订单项已有 token 且配了 `claimLinkBase` → `cert_link = claimLinkBase/token`;
其它情况一律用 `certLink`(老行为不变)
- 领取链接**只认** `claimLinkBase`,绝不回退 `buildClaimUrl`/`orders.claimBaseUrl`
(那是 hash 形式的打手领取页,语义不同)
- 付款建单+发券成功后按 `messageTemplate`(`{link}/{sku}/{orderNo}/{productTitle}`)
经 `order.message` 发给买家;best-effort,失败只记 warn
**买家领取接口**
- `GET/POST /api/v1/claim-skin/:token`(限流、公开);详情含内部SKU/引流标题/商品图/
数量/金额/支付时间/动态表单字段;token 失效给明确 errorCode(404/410/409),不打 500
- 提交账号:先核销平台券再落记录(避免「记账了但券没核销」的资损),已提交幂等
**隐私**
- 买家游戏账号落 `fulfillment_tasks.claim_submission_json`(迁移 096),
**不进** `work_orders.material_json`(那是发给打手的素材);详情接口不返回券码明文/手机号/地址/open_id
293 lines
7.9 KiB
TypeScript
293 lines
7.9 KiB
TypeScript
import type { Request, Response } from 'express'
|
||
import { logDebug, logError, logWarn } from './logger.js'
|
||
|
||
const RESPONSE_TIME_ZONE = 'Asia/Shanghai'
|
||
|
||
export type HttpErrorLike = {
|
||
statusCode?: number
|
||
errorCode?: string
|
||
cause?: unknown
|
||
context?: unknown
|
||
[key: string]: unknown
|
||
}
|
||
|
||
type CreateHttpErrorOptions = {
|
||
statusCode?: number
|
||
errorCode?: string
|
||
cause?: unknown
|
||
context?: unknown
|
||
}
|
||
|
||
export function buildSuccessPayload(data: unknown, msg: string = 'ok') {
|
||
return {
|
||
code: 0,
|
||
msg,
|
||
time: Math.floor(Date.now() / 1000),
|
||
data: normalizeResponseDateTime(data),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 与 `buildSuccessPayload` 同源,但用 `message` 作为文案字段(抖音入站/皮肤领取页等对外契约要求)。
|
||
* 不额外带 `time`,严格贴合这些接口的响应契约。
|
||
*/
|
||
export function buildMessageSuccessPayload(data: unknown, message: string = 'success') {
|
||
return {
|
||
code: 0,
|
||
message,
|
||
data: normalizeResponseDateTime(data),
|
||
}
|
||
}
|
||
|
||
export function buildErrorPayload(error: unknown, fallbackMessage: string) {
|
||
const classification = classifyRouteError(error)
|
||
const errorMessage = error instanceof Error ? String(error.message || '').trim() : ''
|
||
// 依赖方错误(如 affiliate_dash)即使映射为 5xx,也优先透出上游可读原因。
|
||
const preferUpstreamMessage =
|
||
errorMessage &&
|
||
(classification.statusCode < 500 || classification.errorCode.startsWith('affiliate_dash'))
|
||
const message = preferUpstreamMessage
|
||
? errorMessage
|
||
: classification.statusCode >= 500
|
||
? fallbackMessage || '服务内部错误'
|
||
: errorMessage || fallbackMessage
|
||
|
||
return {
|
||
code: 1,
|
||
msg: message,
|
||
errorCode: classification.errorCode,
|
||
time: Math.floor(Date.now() / 1000),
|
||
data: null as null,
|
||
}
|
||
}
|
||
|
||
export function sendRouteError(
|
||
res: Response,
|
||
error: unknown,
|
||
fallbackMessage = '服务内部错误',
|
||
scope = '[route]',
|
||
options: { messageAlias?: boolean } = {},
|
||
): void {
|
||
const classification = classifyRouteError(error)
|
||
const logLevel = resolveRouteErrorLogLevel(classification)
|
||
const logger = logLevel === 'error' ? logError : logLevel === 'debug' ? logDebug : logWarn
|
||
logger(scope, fallbackMessage, {
|
||
statusCode: classification.statusCode,
|
||
errorCode: classification.errorCode,
|
||
// 4xx 属预期失败(鉴权过期、参数错误等),带上完整错误对象只会刷十几行堆栈;仅 5xx 保留排查所需详情。
|
||
...(classification.statusCode >= 500 ? { error } : {}),
|
||
})
|
||
const payload = buildErrorPayload(error, fallbackMessage)
|
||
// 个别对外接口(皮肤领取页)约定用 `message` 而非仓库通用的 `msg`;
|
||
// 这里只做别名追加,不改变 `msg` / `errorCode`,老调用方行为不受影响。
|
||
res
|
||
.status(classification.statusCode)
|
||
.json(options.messageAlias ? { ...payload, message: payload.msg } : payload)
|
||
}
|
||
|
||
export function resolveRouteErrorLogLevel(classification: {
|
||
statusCode: number
|
||
errorCode: string
|
||
}): 'debug' | 'warn' | 'error' {
|
||
if (classification.statusCode >= 500) return 'error'
|
||
if (classification.errorCode === 'worker_auth_expired') return 'debug'
|
||
return 'warn'
|
||
}
|
||
|
||
export function buildNotFoundPayload(req: Request) {
|
||
return {
|
||
code: 1,
|
||
msg: `未实现接口: ${req.method} ${req.originalUrl}`,
|
||
time: Math.floor(Date.now() / 1000),
|
||
data: null as null,
|
||
}
|
||
}
|
||
|
||
export function createHttpError(
|
||
message: string,
|
||
options: CreateHttpErrorOptions = {},
|
||
): Error & HttpErrorLike {
|
||
const { statusCode = 500, errorCode = '', cause, context } = options
|
||
const error = new Error(message) as Error & HttpErrorLike
|
||
error.statusCode = statusCode
|
||
error.errorCode = errorCode || defaultErrorCodeForStatus(statusCode)
|
||
|
||
if (typeof cause !== 'undefined') {
|
||
error.cause = cause
|
||
}
|
||
|
||
if (typeof context !== 'undefined') {
|
||
error.context = context
|
||
}
|
||
|
||
return error
|
||
}
|
||
|
||
function classifyRouteError(error: unknown): {
|
||
statusCode: number
|
||
errorCode: string
|
||
} {
|
||
if (error && typeof error === 'object') {
|
||
const currentError = error as HttpErrorLike
|
||
const statusCode = Number(currentError.statusCode)
|
||
const errorCode = String(currentError.errorCode || '').trim()
|
||
|
||
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600) {
|
||
return {
|
||
statusCode,
|
||
errorCode: errorCode || defaultErrorCodeForStatus(statusCode),
|
||
}
|
||
}
|
||
}
|
||
|
||
const message = error instanceof Error ? error.message : String(error || '')
|
||
|
||
if (message.includes('缺少') || message.includes('无效') || message.includes('验签失败')) {
|
||
return { statusCode: 400, errorCode: 'invalid_request' }
|
||
}
|
||
|
||
if (message.includes('不存在')) {
|
||
return { statusCode: 404, errorCode: 'not_found' }
|
||
}
|
||
|
||
if (message.includes('已关闭')) {
|
||
return { statusCode: 410, errorCode: 'gone' }
|
||
}
|
||
|
||
if (message.includes('不能') || message.includes('冲突')) {
|
||
return { statusCode: 409, errorCode: 'conflict' }
|
||
}
|
||
|
||
return { statusCode: 500, errorCode: 'internal_error' }
|
||
}
|
||
|
||
function defaultErrorCodeForStatus(statusCode: number): string {
|
||
if (statusCode === 400) {
|
||
return 'invalid_request'
|
||
}
|
||
|
||
if (statusCode === 401) {
|
||
return 'unauthorized'
|
||
}
|
||
|
||
if (statusCode === 403) {
|
||
return 'forbidden'
|
||
}
|
||
|
||
if (statusCode === 404) {
|
||
return 'not_found'
|
||
}
|
||
|
||
if (statusCode === 409) {
|
||
return 'conflict'
|
||
}
|
||
|
||
if (statusCode === 410) {
|
||
return 'gone'
|
||
}
|
||
|
||
if (statusCode === 503) {
|
||
return 'dependency_unavailable'
|
||
}
|
||
|
||
return 'internal_error'
|
||
}
|
||
|
||
function normalizeResponseDateTime(value: unknown): unknown {
|
||
if (value instanceof Date) {
|
||
return formatResponseDateTime(value.toISOString())
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
return value.map((item) => normalizeResponseDateTime(item))
|
||
}
|
||
|
||
if (value && typeof value === 'object') {
|
||
return Object.fromEntries(
|
||
Object.entries(value).map(([key, currentValue]) => [
|
||
key,
|
||
normalizeResponseDateTime(currentValue),
|
||
]),
|
||
)
|
||
}
|
||
|
||
if (isIsoDateTimeString(value)) {
|
||
return formatResponseDateTime(value as string)
|
||
}
|
||
|
||
return value
|
||
}
|
||
|
||
function isIsoDateTimeString(value: unknown): boolean {
|
||
return (
|
||
typeof value === 'string' &&
|
||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value.trim())
|
||
)
|
||
}
|
||
|
||
function formatResponseDateTime(value: string): string {
|
||
const date = new Date(value)
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return String(value || '')
|
||
.replace('T', ' ')
|
||
.replace('Z', '')
|
||
}
|
||
|
||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||
timeZone: RESPONSE_TIME_ZONE,
|
||
hour12: false,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
}).formatToParts(date)
|
||
|
||
const tokens = Object.fromEntries(
|
||
parts.filter((item) => item.type !== 'literal').map((item) => [item.type, item.value]),
|
||
)
|
||
|
||
return `${tokens.year}-${tokens.month}-${tokens.day} ${tokens.hour}:${tokens.minute}:${tokens.second}`
|
||
}
|
||
|
||
type RouteHandlerOptions = {
|
||
successMessage?: string
|
||
errorMessage?: string
|
||
scope?: string
|
||
}
|
||
|
||
type RouteFileHandlerOptions = {
|
||
errorMessage?: string
|
||
scope?: string
|
||
}
|
||
|
||
export function createRouteHandler(
|
||
action: (req: Request, res: Response) => unknown | Promise<unknown>,
|
||
{ successMessage = 'ok', errorMessage, scope }: RouteHandlerOptions = {},
|
||
) {
|
||
return async (req: Request, res: Response): Promise<void> => {
|
||
try {
|
||
const data = await action(req, res)
|
||
res.json(buildSuccessPayload(data, successMessage))
|
||
} catch (error) {
|
||
sendRouteError(res, error, errorMessage, scope)
|
||
}
|
||
}
|
||
}
|
||
|
||
export function createRouteFileHandler(
|
||
action: (req: Request, res: Response) => string | Promise<string>,
|
||
{ errorMessage, scope }: RouteFileHandlerOptions = {},
|
||
) {
|
||
return async (req: Request, res: Response): Promise<void> => {
|
||
try {
|
||
const filePath = await action(req, res)
|
||
res.sendFile(filePath)
|
||
} catch (error) {
|
||
sendRouteError(res, error, errorMessage, scope)
|
||
}
|
||
}
|
||
}
|