refactor(douyin-mashida)!: 纠偏——删掉自建的皮肤通道,改回复用 affiliate_dash

上一轮我把皮肤交付做成了「另建一套执行器 + 独立领取接口 + 只提交账号就结束」,
跟买家实际要走的过程不一致(真正的完成信号是「扫码绑定后的 UID == 买家填的 UID」),
而且核销还触发了老通知逻辑,给买家**又发了一条 /redeem** —— 买家收到两条链接。
本次按用户要求改回**完全复用仓库现成的 affiliate_dash 通道**(= 旧的快手皮肤自动发货)。

**删掉**
- 执行器 `douyin_mashida_skin`(含 registry/types/planner/bootstrap/路由白名单注册)
- `services/claim/claim-skin-service.ts`、`routes/claim-skin.ts`、`/api/v1/claim-skin` 挂载
- 迁移 096 的 `claim_submission_json` 全链路(task-repo / rows / inputs / task-detail /
  admin-read-service 的 claimSubmission),并新增 **097 兜底迁移**把该列 DROP 掉
- 顺带清掉上一轮专为已删接口加的死代码(utils/http.ts 的 buildMessageSuccessPayload 等)

**改回复用**
- `resolveOrderItemClaimLink`:通用解析(只看订单项、不看执行器)→
  `buildConfiguredClaimUrl(claimLinkBase, token)`;多履约单元取 task id 最小并告警;
  **绝不回退** hash 形态的 `buildClaimUrl`
- `cert_link` 与付款后那条**唯一**消息共用同一个解析结果
- **核销后不再给抖音买家发第二条消息**(`shouldNotifyBuyerAfterVerify` 只放行非抖音 provider)
- 买家侧走既有流程:输 UID → 二维码 → 扫码绑定 → 比对 UID → delivered/REDEEMED;
  在 delivered 分支 best-effort 核销平台券(幂等、失败只 warn,不拖慢买家响应)
- 后台回显链接改按 **order.provider** 判断(抖音用 `claimLinkBase`)

**SQL 守卫**:允许「删除历史迁移」,但强制要求「同时新增兜底迁移」配对
(修改历史迁移仍禁止),并把改动来源换成 `git status --porcelain`(否则未跟踪的新迁移看不见)

`npm run check`:tests 671 / pass 668 / fail 0 / skipped 3(含新增的物品图回退用例)
This commit is contained in:
yml2213
2026-10-09 16:13:39 +08:00
parent 56ad06239b
commit 4fce07a621
37 changed files with 590 additions and 1300 deletions
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveClaimTokenExpiration, buildSkinClaimUrl } from './claim-service.js'
import {
buildConfiguredClaimUrl,
pickOrderItemClaimTask,
resolveClaimTokenExpiration,
} from './claim-service.js'
test('领取链接默认可配置为长期有效', () => {
assert.equal(resolveClaimTokenExpiration('2026-08-11T08:00:00.000Z', 0), null)
@@ -14,24 +18,69 @@ test('领取链接保留正整数小时的限时配置能力', () => {
)
})
test('皮肤领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app/claim', 'AbC123'),
buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'),
'https://x.ngrok-free.app/#/claim/AbC123',
)
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(
buildSkinClaimUrl('https://x.ngrok-free.app', 'AbC123'),
buildConfiguredClaimUrl('https://x.ngrok-free.app', 'AbC123'),
'https://x.ngrok-free.app/AbC123',
)
})
test('皮肤领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => {
assert.equal(buildSkinClaimUrl('', 'AbC123'), '')
assert.equal(buildSkinClaimUrl(undefined, 'AbC123'), '')
assert.equal(buildSkinClaimUrl('https://x/claim', ''), '')
assert.equal(buildSkinClaimUrl('https://x/claim', null), '')
test('配置化领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => {
assert.equal(buildConfiguredClaimUrl('', 'AbC123'), '')
assert.equal(buildConfiguredClaimUrl(undefined, 'AbC123'), '')
assert.equal(buildConfiguredClaimUrl('https://x/claim', ''), '')
assert.equal(buildConfiguredClaimUrl('https://x/claim', null), '')
})
test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => {
const picked = pickOrderItemClaimTask(
[
{ id: 12, order_item_id: 7, claim_token: 'token-12' },
{ id: 10, order_item_id: 7, primary_claim_token: 'token-10' },
{ id: 9, order_item_id: 7, claim_token: '' },
{ id: 11, order_item_id: 8, claim_token: 'token-11' },
],
7,
)
assert.deepEqual(picked, {
taskId: 10,
token: 'token-10',
taskCount: 3,
tokenTaskCount: 2,
})
})
test('pickOrderItemClaimTask 区分「有任务但没 token」与「压根没任务」', () => {
const withoutToken = pickOrderItemClaimTask(
[
{ id: 1, order_item_id: 7, claim_token: '' },
{ id: 2, order_item_id: 7, claim_token: '' },
],
7,
)
assert.equal(withoutToken.taskId, null)
assert.equal(withoutToken.token, '')
assert.equal(withoutToken.taskCount, 2)
assert.equal(withoutToken.tokenTaskCount, 0)
const empty = { taskId: null, token: '', taskCount: 0, tokenTaskCount: 0 }
assert.deepEqual(
pickOrderItemClaimTask([{ id: 1, order_item_id: 8, claim_token: 'x' }], 7),
empty,
)
assert.deepEqual(pickOrderItemClaimTask([], 7), empty)
assert.deepEqual(pickOrderItemClaimTask(null, 7), empty)
assert.deepEqual(
pickOrderItemClaimTask([{ id: 1, order_item_id: 7, claim_token: 'x' }], ''),
empty,
)
})
@@ -1,8 +1,11 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createClaimToken } from '../../repositories/claim-token-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { addHours, nowIso } from '../../utils/time.js'
import { randomToken } from '../../utils/random.js'
import type { TaskRow } from '../../types/repository/rows.js'
type TaskClaimToken = NonNullable<Awaited<ReturnType<typeof createClaimToken>>> & {
claimUrl: string
@@ -45,21 +48,25 @@ export function resolveClaimTokenExpiration(
return Number.isFinite(ttlHours) && ttlHours > 0 ? addHours(createdAt, ttlHours) : null
}
/**
* 全局默认领取链接:`runtimeConfig.orders.claimBaseUrl` + `/` + token。
*
* ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接
* 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。
*/
export function buildClaimUrl(token: string): string {
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
}
/**
* 「皮肤自动发货」领取链接:`${领取页基址去尾斜杠}/${token}`。
* 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。
*
* ⚠️ 与 `buildClaimUrl` 刻意分开,绝不互相回退:
* `buildClaimUrl` 读 `runtimeConfig.orders.claimBaseUrl`,默认值是**hash 形式**的打手领取页
* (`http://127.0.0.1:5173/#/claim`,会走游戏 UID 绑定那套流程);皮肤买家页是**路径式**独立页面
* (`/claim/:token`),基址只能来自抖音配置的 `claimLinkBase`。
* 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页),
* 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
*/
export function buildSkinClaimUrl(baseUrl: unknown, token: unknown): string {
export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string {
const base = String(baseUrl || '')
.trim()
.replace(/\/+$/, '')
@@ -69,3 +76,112 @@ export function buildSkinClaimUrl(baseUrl: unknown, token: unknown): string {
}
return `${base}/${normalizedToken}`
}
export type OrderItemClaimLink = {
/** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */
claimUrl: string
/** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */
taskId?: number
/** 该订单项下的履约任务总数(含没有 token 的单元),供调用方判断「有任务但还没有 token」 */
taskCount: number
}
/** 履约任务上的领取 token:主 claim token 优先,其次任务自身的 claim_token。 */
export function resolveTaskClaimToken(task: Partial<TaskRow> | null | undefined): string {
return String(task?.primary_claim_token || task?.claim_token || '').trim()
}
export type OrderItemClaimTaskPick = {
/** 命中的履约单元(task id 最小且带 token);没有则为 null */
taskId: number | null
token: string
/** 该订单项下的履约任务总数,不论有没有 token */
taskCount: number
/** 其中带 claim token 的单元数(>1 时会有多个领取链接) */
tokenTaskCount: number
}
/**
* 纯函数:从订单的履约任务里挑出该订单项的领取单元。
*
* 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token;
* 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。
*/
export function pickOrderItemClaimTask(
tasks: Array<Partial<TaskRow>> | null | undefined,
orderItemId: number | string,
): OrderItemClaimTaskPick {
const empty: OrderItemClaimTaskPick = {
taskId: null,
token: '',
taskCount: 0,
tokenTaskCount: 0,
}
const normalizedOrderItemId = Number(orderItemId)
if (!Number.isFinite(normalizedOrderItemId)) {
return empty
}
const matched = (Array.isArray(tasks) ? tasks : [])
.filter((task) => Number(task?.order_item_id) === normalizedOrderItemId)
.map((task) => ({ taskId: Number(task?.id), token: resolveTaskClaimToken(task) }))
.filter((entry) => Number.isFinite(entry.taskId))
const withToken = matched
.filter((entry) => Boolean(entry.token))
.sort((left, right) => left.taskId - right.taskId)
const picked = withToken[0]
return {
taskId: picked ? picked.taskId : null,
token: picked ? picked.token : '',
taskCount: matched.length,
tokenTaskCount: withToken.length,
}
}
/**
* 通用领取链接解析(不再依赖任何具体执行器):
* 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。
*
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token,
* 因此这里只按 `order_item_id` 找任务、**不看 executor_key**:
* 换执行器不会再让领取链接解析失效。
*/
export async function resolveOrderItemClaimLink(input: {
orderId: number | string
orderItemId: number | string
claimLinkBase: unknown
}): Promise<OrderItemClaimLink> {
const orderId = Number(input.orderId)
if (!Number.isFinite(orderId)) {
return { claimUrl: '', taskCount: 0 }
}
const picked = pickOrderItemClaimTask(await listTasksByOrderId(orderId), input.orderItemId)
if (picked.tokenTaskCount > 1) {
logIntegration(
'[claim/claim-service]',
'同一订单项存在多个履约领取单元,领取链接取 task id 最小的一个',
{
orderId,
orderItemId: Number(input.orderItemId),
tokenTaskCount: picked.tokenTaskCount,
pickedTaskId: picked.taskId,
},
{ level: 'warn' },
)
}
if (picked.taskId === null || !picked.token) {
return { claimUrl: '', taskCount: picked.taskCount }
}
return {
claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token),
taskId: picked.taskId,
taskCount: picked.taskCount,
}
}
@@ -1,249 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
assertValidSkinGameAccount,
buildSkinClaimDetailPayload,
normalizeSkinGameAccount,
parseSkinClaimSubmission,
resolveSkinClaimOrderNo,
resolveSkinClaimStatus,
resolveSkinClaimTokenStatus,
SKIN_CLAIM_FIELDS,
SKIN_GAME_ACCOUNT_MAX_LENGTH,
} from './claim-skin-service.js'
import { buildMessageSuccessPayload } from '../../utils/http.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
function createOrder(overrides: Partial<OrderRow> = {}): OrderRow {
return {
id: 1,
provider: 'douyin_mashida',
platform: 'doudian',
shop_id: 'shop-1',
shop_name: '测试小店',
platform_order_id: '6930318258716179555',
order_status: 'paid',
pay_status: 'paid',
buyer_id: 'open-id-should-not-leak',
buyer_name: '买家张三',
receiver_contact: '13800000000',
total_amount: 100,
currency: 'CNY',
raw_payload_json: {},
paid_at: '2026-10-09T05:18:20.000Z',
created_at: '2026-10-09T05:18:20.000Z',
updated_at: '2026-10-09T05:18:20.000Z',
...overrides,
}
}
function createOrderItem(overrides: Partial<OrderItemRow> = {}): OrderItemRow {
return {
id: 11,
order_id: 1,
sku_code: '幸运币2个',
sku_name: DOUYIN_SKIN_TITLE,
quantity: 1,
spec_json: {},
item_snapshot_json: {
douyinSubOrderId: '6930318258716179555',
code: '幸运币2个',
productName: DOUYIN_SKIN_TITLE,
},
...overrides,
}
}
function createTask(overrides: Partial<TaskRow> = {}): Partial<TaskRow> {
return {
id: 9,
executor_key: 'douyin_mashida_skin',
task_status: 'link_generated',
user_action_status: 'pending_claim',
claim_submission_json: {},
...overrides,
}
}
test('领取页详情严格贴合前端契约字段', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
assert.deepEqual(payload, {
tokenStatus: 'active',
flowType: 'douyin_mashida_skin',
orderNo: '6930318258716179555',
productName: '幸运币2个',
productTitle: DOUYIN_SKIN_TITLE,
imageUrl: '/images/products/lucky_coin_x2.webp',
quantity: 1,
amountFen: 100,
paidAt: '2026-10-09T05:18:20.000Z',
status: 'pending_claim',
claimFields: [
{
key: 'gameAccount',
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
],
submittedGameAccount: '',
})
})
test('领取页详情不泄露券码/买家手机号/地址/open_id', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
const serialized = JSON.stringify(payload)
assert.equal(serialized.includes('open-id-should-not-leak'), false)
assert.equal(serialized.includes('13800000000'), false)
assert.equal(serialized.includes('买家张三'), false)
assert.equal(serialized.includes('cert'), false)
})
test('paidAt 经响应归一化后是北京时间字符串(前端直接展示)', () => {
const response = buildMessageSuccessPayload(
buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
}),
)
assert.equal(response.code, 0)
assert.equal(response.message, 'success')
assert.equal((response.data as { paidAt: string }).paidAt, '2026-10-09 13:18:20')
})
test('已提交的任务返回 submitted + 买家填写的账号(token 显示 used 但仍可回看)', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: {
id: 1,
token: 'AbC123',
// 提交后 token 保持 status=active(否则 getClaimContext 会拦截),只标记 used_at/used_count
status: 'active',
expired_at: null,
used_at: '2026-10-10T00:00:00.000Z',
used_count: 1,
max_use_count: 1,
},
task: createTask({
task_status: 'claimed',
claim_submission_json: { gameAccount: '123456', submittedAt: '2026-10-10T00:00:00.000Z' },
}),
order: createOrder(),
orderItem: createOrderItem(),
})
assert.equal(payload.status, 'submitted')
assert.equal(payload.submittedGameAccount, '123456')
assert.equal(payload.tokenStatus, 'used')
})
test('resolveSkinClaimTokenStatus 覆盖 active/closed/revoked/expired/used', () => {
const now = Date.parse('2026-10-10T00:00:00.000Z')
assert.equal(resolveSkinClaimTokenStatus({ status: 'active', expired_at: null }, now), 'active')
assert.equal(resolveSkinClaimTokenStatus({ status: 'closed' }, now), 'closed')
assert.equal(resolveSkinClaimTokenStatus({ status: 'revoked' }, now), 'revoked')
assert.equal(resolveSkinClaimTokenStatus({ status: 'expired' }, now), 'expired')
assert.equal(
resolveSkinClaimTokenStatus({ status: 'active', expired_at: '2026-10-09T00:00:00.000Z' }, now),
'expired',
)
assert.equal(
resolveSkinClaimTokenStatus({ status: 'active', used_at: '2026-10-09T00:00:00.000Z' }, now),
'used',
)
assert.equal(
resolveSkinClaimTokenStatus(
{ status: 'active', expired_at: null, max_use_count: 1, used_count: 1 },
now,
),
'used',
)
})
test('parseSkinClaimSubmission 兼容 JSONB 对象与字符串,坏数据不抛错', () => {
assert.deepEqual(
parseSkinClaimSubmission({ claim_submission_json: { gameAccount: ' 123 ', submittedAt: 't' } }),
{ gameAccount: '123', submittedAt: 't' },
)
assert.deepEqual(parseSkinClaimSubmission({ claim_submission_json: '{"gameAccount":"abc"}' }), {
gameAccount: 'abc',
submittedAt: '',
})
assert.deepEqual(parseSkinClaimSubmission({ claim_submission_json: '{invalid' }), {
gameAccount: '',
submittedAt: '',
})
assert.deepEqual(parseSkinClaimSubmission(null), { gameAccount: '', submittedAt: '' })
})
test('resolveSkinClaimStatus 以提交记录为准,claimed 状态也算已领取', () => {
assert.equal(resolveSkinClaimStatus(createTask()), 'pending_claim')
assert.equal(resolveSkinClaimStatus(createTask({ task_status: 'claimed' })), 'submitted')
assert.equal(
resolveSkinClaimStatus(createTask({ claim_submission_json: { gameAccount: '123' } })),
'submitted',
)
})
test('游戏账号归一化与校验', () => {
assert.equal(normalizeSkinGameAccount(' 123456\u200B '), '123456')
assert.equal(assertValidSkinGameAccount(' 123456 '), '123456')
assert.throws(
() => assertValidSkinGameAccount(' '),
(error: { errorCode?: string; statusCode?: number }) =>
error.errorCode === 'claim_skin_game_account_required' && error.statusCode === 400,
)
const tooLong = 'a'.repeat(SKIN_GAME_ACCOUNT_MAX_LENGTH + 1)
assert.throws(
() => assertValidSkinGameAccount(tooLong),
(error: { errorCode?: string; statusCode?: number }) =>
error.errorCode === 'claim_skin_game_account_too_long' && error.statusCode === 400,
)
assert.equal('a'.repeat(SKIN_GAME_ACCOUNT_MAX_LENGTH).length, SKIN_GAME_ACCOUNT_MAX_LENGTH)
})
test('resolveSkinClaimOrderNo 子订单号优先、父单兜底', () => {
assert.equal(resolveSkinClaimOrderNo(createOrder(), createOrderItem()), '6930318258716179555')
assert.equal(
resolveSkinClaimOrderNo(
createOrder({ platform_order_id: 'parent-1' }),
createOrderItem({ item_snapshot_json: {} }),
),
'parent-1',
)
})
test('claimFields 每次都是副本,调用方修改不会污染常量', () => {
const payload = buildSkinClaimDetailPayload({
claimToken: { id: 1, token: 'AbC123', status: 'active', expired_at: null },
task: createTask(),
order: createOrder(),
orderItem: createOrderItem(),
})
const firstField = payload.claimFields[0]
assert.ok(firstField)
firstField.label = '被改了'
assert.equal(SKIN_CLAIM_FIELDS[0]?.label, '游戏账号 / 角色ID')
})
@@ -1,324 +0,0 @@
import { TASK_STATUS } from '../../domain/task-status.js'
import { updateClaimToken } from '../../repositories/claim-token-repo.js'
import { updateTask } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { normalizeFen } from '../../utils/money.js'
import { nowIso } from '../../utils/time.js'
import { getClaimContext } from './kuaishou-cloud-claim-context.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import { FULFILLMENT_EXECUTOR_KEYS } from '../fulfillment/executors/types.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
import { extractDouyinMashidaSubOrderId } from '../platforms/douyin-mashida/order-mapper.js'
import { verifyCertificateForOrderItem } from '../redeem/certificate-service.js'
/**
* 抖音码速达「皮肤自动发货」买家领取页(公开接口,无需登录)。
*
* 契约(前端并行开发中,字段名不可改):
* - `GET /api/v1/claim-skin/:token`
* - `POST /api/v1/claim-skin/:token` body `{ gameAccount }`
*
* 隐私红线:页面只暴露脱敏信息 —— 内部 SKU、引流标题、商品图、金额、支付时间、买家自己填的账号;
* **不返回**券码明文、买家手机号/地址/open_id,也不把账号写进 work_orders.material_json
* (落 `fulfillment_tasks.claim_submission_json`,见迁移 096)。
*/
export const SKIN_CLAIM_FLOW_TYPE = 'douyin_mashida_skin'
export const SKIN_GAME_ACCOUNT_FIELD_KEY = 'gameAccount'
export const SKIN_GAME_ACCOUNT_MAX_LENGTH = 64
export const SKIN_CLAIM_SUCCESS_MESSAGE = '领取成功,我们会尽快把物品发到你的账号'
export type SkinClaimTokenStatus = 'active' | 'closed' | 'revoked' | 'expired' | 'used'
export type SkinClaimStatus = 'pending_claim' | 'submitted'
/** claim_tokens 的完整行(公共 Row 类型未列 used_* 字段,这里按仓储实际列补齐)。 */
export type SkinClaimTokenLike = Partial<ClaimTokenRow> & {
used_at?: string | null
max_use_count?: number | null
used_count?: number | null
}
export type SkinClaimField = {
key: string
label: string
placeholder: string
required: boolean
}
/** 领取页需要买家填的字段(目前只有游戏账号;前端按此渲染表单)。 */
export const SKIN_CLAIM_FIELDS: SkinClaimField[] = [
{
key: SKIN_GAME_ACCOUNT_FIELD_KEY,
label: '游戏账号 / 角色ID',
placeholder: '请输入你的游戏账号(角色ID)',
required: true,
},
]
export type SkinClaimSubmission = {
gameAccount: string
submittedAt: string
}
export type SkinClaimDetailPayload = {
tokenStatus: SkinClaimTokenStatus
flowType: typeof SKIN_CLAIM_FLOW_TYPE
orderNo: string
productName: string
productTitle: string
imageUrl: string
quantity: number
amountFen: number
paidAt: string | null
status: SkinClaimStatus
claimFields: SkinClaimField[]
submittedGameAccount: string
}
export type SkinClaimSubmitPayload = {
status: SkinClaimStatus
message: string
}
/** token 状态归一化:DB 里的状态 + 过期/用尽信息 → 契约里的 5 个值。 */
export function resolveSkinClaimTokenStatus(
claimToken: SkinClaimTokenLike | null | undefined,
nowMs: number = Date.now(),
): SkinClaimTokenStatus {
const status = String(claimToken?.status || '').trim()
if (status === 'closed' || status === 'revoked' || status === 'used') {
return status
}
if (status === 'expired') {
return 'expired'
}
const expiredAt = claimToken?.expired_at ? Date.parse(String(claimToken.expired_at)) : NaN
if (Number.isFinite(expiredAt) && expiredAt <= nowMs) {
return 'expired'
}
const usedAt = String(claimToken?.used_at || '').trim()
const maxUseCount = Number(claimToken?.max_use_count || 0)
const usedCount = Number(claimToken?.used_count || 0)
if (usedAt || (maxUseCount > 0 && usedCount >= maxUseCount)) {
return 'used'
}
return 'active'
}
/** 从任务的 claim_submission_json 读取买家提交内容(兼容 PG 返回对象或字符串)。 */
export function parseSkinClaimSubmission(
task: Partial<TaskRow> | null | undefined,
): SkinClaimSubmission {
const raw = task?.claim_submission_json
let parsed: unknown = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
parsed = null
}
}
if (Object.prototype.toString.call(parsed) !== '[object Object]') {
return { gameAccount: '', submittedAt: '' }
}
const record = parsed as Record<string, unknown>
return {
gameAccount: String(record.gameAccount || '').trim(),
submittedAt: String(record.submittedAt || '').trim(),
}
}
/** 待领取 / 已领取:以「任务上是否已有提交记录」为准,保证重复提交幂等。 */
export function resolveSkinClaimStatus(task: Partial<TaskRow> | null | undefined): SkinClaimStatus {
const submission = parseSkinClaimSubmission(task)
if (submission.gameAccount || submission.submittedAt) {
return 'submitted'
}
const taskStatus = String(task?.task_status || '').trim()
if (
taskStatus === TASK_STATUS.CLAIMED ||
taskStatus === TASK_STATUS.REDEEMED ||
taskStatus === TASK_STATUS.COMPLETED
) {
return 'submitted'
}
return 'pending_claim'
}
/** 游戏账号归一化:去掉首尾空白与不可见字符。 */
export function normalizeSkinGameAccount(value: unknown): string {
return String(value ?? '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
}
/** 校验并返回可入库的游戏账号;不合法直接抛 4xx(不产生 500)。 */
export function assertValidSkinGameAccount(value: unknown): string {
const gameAccount = normalizeSkinGameAccount(value)
if (!gameAccount) {
throw createHttpError('请填写游戏账号(角色ID)', {
statusCode: 400,
errorCode: 'claim_skin_game_account_required',
})
}
if (gameAccount.length > SKIN_GAME_ACCOUNT_MAX_LENGTH) {
throw createHttpError(`游戏账号过长(最多 ${SKIN_GAME_ACCOUNT_MAX_LENGTH} 个字符)`, {
statusCode: 400,
errorCode: 'claim_skin_game_account_too_long',
context: { length: gameAccount.length, maxLength: SKIN_GAME_ACCOUNT_MAX_LENGTH },
})
}
return gameAccount
}
/** 订单号口径与回执一致:子订单号优先、父单兜底。 */
export function resolveSkinClaimOrderNo(order: OrderRow, orderItem: OrderItemRow): string {
const subOrderId = extractDouyinMashidaSubOrderId(orderItem.item_snapshot_json)
return String(subOrderId || order.platform_order_id || '').trim()
}
/** 纯函数:把 token/任务/订单拼成领取页详情(便于单测,不碰数据库)。 */
export function buildSkinClaimDetailPayload(input: {
claimToken: SkinClaimTokenLike
task: Partial<TaskRow>
order: OrderRow
orderItem: OrderItemRow
nowMs?: number
}): SkinClaimDetailPayload {
const { claimToken, task, order, orderItem } = input
const submission = parseSkinClaimSubmission(task)
const productName = String(orderItem.sku_code || orderItem.sku_name || '').trim()
const productTitle = String(orderItem.sku_name || productName).trim()
return {
tokenStatus: resolveSkinClaimTokenStatus(claimToken, input.nowMs),
flowType: SKIN_CLAIM_FLOW_TYPE,
orderNo: resolveSkinClaimOrderNo(order, orderItem),
productName,
productTitle,
imageUrl: resolveProductImageUrl(productName),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
amountFen: normalizeFen(order.total_amount),
paidAt: order.paid_at || null,
status: resolveSkinClaimStatus(task),
claimFields: SKIN_CLAIM_FIELDS.map((field) => ({ ...field })),
submittedGameAccount: submission.gameAccount,
}
}
function assertSkinTask(task: Partial<TaskRow>): TaskRow {
if (String(task.executor_key || '').trim() !== FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN) {
throw createHttpError('该领取链接不是皮肤自动发货订单', {
statusCode: 409,
errorCode: 'claim_skin_task_invalid',
context: { taskId: task.id, executorKey: task.executor_key || '' },
})
}
return task as TaskRow
}
/** `GET /api/v1/claim-skin/:token`:领取页详情(token 失效/过期/关闭由 getClaimContext 抛明确 4xx)。 */
export async function getSkinClaimDetail(token: unknown): Promise<SkinClaimDetailPayload> {
const context = await getClaimContext(token)
assertSkinTask(context.task)
return buildSkinClaimDetailPayload({
claimToken: context.claimToken,
task: context.task,
order: context.order,
orderItem: context.orderItem,
})
}
/**
* `POST /api/v1/claim-skin/:token`:提交游戏账号 + 核销平台券。
*
* 幂等:已有提交记录直接返回「已领取」,不重复核销、不报错。
*/
export async function submitSkinClaim(
token: unknown,
payload: { gameAccount?: unknown } = {},
): Promise<SkinClaimSubmitPayload> {
const context = await getClaimContext(token)
const task = assertSkinTask(context.task)
if (parseSkinClaimSubmission(task).gameAccount) {
logIntegration('[claim-skin]', '重复提交已忽略(任务已领取)', {
taskId: task.id,
platformOrderId: task.platform_order_id,
})
return { status: 'submitted', message: SKIN_CLAIM_SUCCESS_MESSAGE }
}
const gameAccount = assertValidSkinGameAccount(payload.gameAccount)
// 先核销平台券(verifyCertificate 自带幂等:本地已核销直接返回);
// 核销失败不落提交记录,买家可重试,避免「我们记了已领取但平台券没核销」的资损。
await verifyCertificateForOrderItem({ order: context.order, orderItem: context.orderItem })
const now = nowIso()
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.CLAIMED,
user_action_status: 'not_required',
claimed_at: task.claimed_at || now,
// 买家账号只落任务自身字段(隐私红线:不进 work_orders.material_json)
claim_submission_json: {
gameAccount,
submittedAt: now,
source: 'claim_skin',
},
last_error: '',
updated_at: now,
})
if (!updatedTask) {
throw createHttpError('领取结果保存失败,请稍后重试', {
statusCode: 500,
errorCode: 'claim_skin_submit_failed',
context: { taskId: task.id },
})
}
logIntegration('[claim-skin]', '皮肤订单已领取(游戏账号已提交,平台券已核销)', {
taskId: task.id,
platformOrderId: task.platform_order_id,
orderId: context.order.id,
orderItemId: context.orderItem.id,
})
// 把 token 标记为「已使用」(used_at/used_count),领取页据此显示 used 状态。
// ⚠️ 刻意**不改 status**(保持 active):`getClaimContext` 会拦截非 active 的 token,
// 改成 used 会让买家重开链接看到「链接不可用」,破坏「重复打开/重复提交幂等」。
// 幂等读已成功落库,这里失败只记 warn,不把已完成的领取回报成失败。
await updateClaimToken(context.claimToken.id, {
used_at: now,
used_count: 1,
updated_at: now,
}).catch((error) => {
logIntegration(
'[claim-skin]',
'标记领取 token 已使用失败(领取结果已保存)',
{
taskId: task.id,
claimTokenId: context.claimToken.id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
})
return { status: 'submitted', message: SKIN_CLAIM_SUCCESS_MESSAGE }
}
@@ -1,7 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { CLAIM_TERMINAL_STATUSES } from './kuaishou-cloud-claim-context.js'
import {
CLAIM_TERMINAL_STATUSES,
resolveClaimProductImageUrl,
} from './kuaishou-cloud-claim-context.js'
import { TASK_STATUS } from '../../domain/task-status.js'
test('CLAIM_TERMINAL_STATUSES 包含 closed 与 expired 状态', () => {
@@ -10,3 +13,31 @@ test('CLAIM_TERMINAL_STATUSES 包含 closed 与 expired 状态', () => {
assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.PAID), false)
assert.equal(CLAIM_TERMINAL_STATUSES.has(TASK_STATUS.LINK_GENERATED), false)
})
test('领取页物品图:内部 SKU 落哪个字段都能找到图(多字段回退)', () => {
// 正常口径:sku_code 就是中文内部 SKU
assert.equal(
resolveClaimProductImageUrl({ sku_code: '幸运币2个', sku_name: '【测试专用】引流标题' }),
'/images/products/lucky_coin_x2.webp',
)
// sku_code 被 affiliate-dash 英文 sku 覆盖:靠 spec_json.code 兜回来
assert.equal(
resolveClaimProductImageUrl({
sku_code: 'lucky_coin_x2',
sku_name: '幸运币2个',
spec_json: JSON.stringify({ code: '幸运币2个' }),
}),
'/images/products/lucky_coin_x2.webp',
)
// 只剩中文 sku_name 时也能兜底
assert.equal(
resolveClaimProductImageUrl({ sku_code: 'lucky_coin_x2', sku_name: '套装-浪漫天命' }),
'/images/products/suit_romantic_destiny.webp',
)
// 都映射不到 → 空串(前端展示占位块,不猜路径)
assert.equal(resolveClaimProductImageUrl({ sku_code: '未知商品', sku_name: '' }), '')
assert.equal(resolveClaimProductImageUrl({}), '')
})
@@ -8,6 +8,7 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json
import { nowIso } from '../../utils/time.js'
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.js'
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
import { buildClaimIdentityPayload } from './claim-identity.js'
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
@@ -188,6 +189,53 @@ function buildAffiliateDashClaimDetailPayload({
}
}
/**
* 领取页物品图片(内部 SKU → 图片 URL)。
*
* 内部 SKU(中文,如「幸运币2个」)在不同写入口径下可能落在不同字段:平台 `code` 先写进
* `sku_code`,履约解析后 `sku_code` 又可能被 affiliate-dash 的英文 sku 覆盖。
* 这里以「能映射出图片」为准则逐个回退,避免换口径就丢图;都映射不到返回空串(前端占位)。
*/
export function resolveClaimProductImageUrl(orderItem: Partial<OrderItemRow>): string {
const spec = parseJsonRecord(orderItem.spec_json)
const snapshot = parseJsonRecord(orderItem.item_snapshot_json)
return pickFirstNonEmptyText([
resolveProductImageUrl(spec.code),
resolveProductImageUrl(snapshot.code),
resolveProductImageUrl(orderItem.sku_name),
resolveProductImageUrl(orderItem.sku_code),
])
}
function parseJsonRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
const text = String(value ?? '').trim()
if (!text) {
return {}
}
try {
const parsed: unknown = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
function pickFirstNonEmptyText(values: unknown[]): string {
for (const value of values) {
const text = String(value ?? '').trim()
if (text) {
return text
}
}
return ''
}
function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const executorKey = String(task.executor_key || '').trim()
const context = parseTaskContext(task)
@@ -217,6 +265,8 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }:
product: {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
// 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp
imageUrl: resolveClaimProductImageUrl(orderItem),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
@@ -263,6 +313,8 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
product: {
title: productTitle,
skuCode: String(orderItem.sku_code || '').trim(),
// 领取页物品图片:内部 SKU(中文)→ 英文 sku → /images/products/*.webp
imageUrl: resolveClaimProductImageUrl(orderItem),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [
@@ -1,6 +1,7 @@
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { getTaskById, updateTask } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import type { JsonObject } from '../../types/json.js'
@@ -14,13 +15,15 @@ import {
getAffiliateDashBindResult,
submitAffiliateDashDelivery,
} from '../platforms/affiliate-dash/order-service.js'
import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js'
import { verifyCertificateForOrderItem } from '../redeem/certificate-service.js'
import {
assertValidClaimUid,
canUpdateClaimUid,
getClaimIdentityFromTask,
} from './claim-identity.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import type { TaskRow } from '../../types/repository/rows.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
@@ -456,11 +459,48 @@ export async function submitAffiliateDashClaim(
if (refreshedTask) {
await syncAffiliateDashTaskStatus(refreshedTask, { snapshotOnly: true })
}
// 交付完成(买家扫码绑定 + 系统比对「绑定 UID == 填的 UID」通过):
// 抖音码速达订单要在这里把**平台券**核销掉(幂等,已核销直接返回)。
// 刻意不 await:平台核销在业务错误时要 ~10s 才返回,挂在领取响应上会让买家以为领取失败;
// 失败只记 warn,绝不影响买家侧的领取完成。
verifyDouyinCertificateAfterClaim(context)
}
return getKuaishouCloudClaimDetail(token)
}
/**
* 抖音订单在 affiliate-dash 交付完成时 best-effort 核销平台券。
*
* 非抖音订单(provider 不匹配)直接跳过,保持原有行为。
*/
function verifyDouyinCertificateAfterClaim(context: {
order: OrderRow
orderItem: OrderItemRow
}): void {
if (!isDouyinMashidaProvider(context.order.provider)) {
return
}
void verifyCertificateForOrderItem({
order: context.order,
orderItem: context.orderItem,
}).catch((error) => {
logIntegration(
'[claim]',
'交付完成后核销平台券失败(不影响买家领取完成,需人工跟进)',
{
orderId: context.order.id,
orderItemId: context.orderItem.id,
platformOrderId: context.order.platform_order_id,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
})
}
type AffiliateDashFlowSnapshot = ReturnType<typeof normalizeAffiliateDashFlow>
function resolveAffiliateDashExpectedGameAccount(