Files
order_site/apps/backend/src/utils/http.ts
T
yml2213 56a8184576 feat(douyin-mashida): 皮肤自动发货(按内部 SKU 分流 + 领取链接 + 买家领取接口)
抖音码速达的皮肤类商品改为「自动发货 + 领取链接」:买家不用输订单号,点链接直接领。

**分流(按内部 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
2026-10-09 15:06:43 +08:00

293 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}