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