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:
@@ -97,9 +97,13 @@ for (const file of listSourceFiles(path.join(BACKEND_ROOT, 'src'))) {
|
||||
}
|
||||
|
||||
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
||||
//
|
||||
// 例外(刻意保留):**删除**历史迁移是允许的,但必须同时新增迁移兜底
|
||||
// (例如「迁移 096 设计作废 → 删 096、新增 097 DROP COLUMN」这种纠偏):
|
||||
// 只删不补会让「已应用过 096 的环境」与「全新库」结构漂移,所以这里强制配对。
|
||||
try {
|
||||
const diffOutputs = [
|
||||
execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], {
|
||||
const nameStatusOutputs = [
|
||||
execFileSync('git', ['status', '--porcelain', '--', 'src/db/migrations'], {
|
||||
cwd: BACKEND_ROOT,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
@@ -108,14 +112,41 @@ try {
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
]
|
||||
const changedHistoricalMigrations = diffOutputs
|
||||
.flatMap((output) => output.split('\n'))
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
|
||||
.map(([status, file]) => `${status} ${file}`)
|
||||
const migrationChanges = nameStatusOutputs.flatMap((output) =>
|
||||
output
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
// `git status --porcelain` = `XY path`(未跟踪为 `?? path`);
|
||||
// `git diff --name-status` = `S\tpath`(重命名为 `R100\told\tnew`)。两者格式不同,分别解析。
|
||||
if (line.includes('\t')) {
|
||||
const parts = line.split('\t').map((part) => part.trim())
|
||||
return { status: parts[0] || '', file: parts[parts.length - 1] || '' }
|
||||
}
|
||||
return { status: line.slice(0, 2).trim(), file: line.slice(3).trim() }
|
||||
})
|
||||
.filter((change) => change.file.endsWith('.sql')),
|
||||
)
|
||||
|
||||
const changedHistoricalMigrations = migrationChanges
|
||||
.filter((change) => change.status !== 'A' && change.status !== 'D' && change.status !== '??')
|
||||
.map((change) => `${change.status} ${change.file}`)
|
||||
if (changedHistoricalMigrations.length > 0) {
|
||||
failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
|
||||
}
|
||||
|
||||
const deletedMigrations = migrationChanges.filter((change) => change.status === 'D')
|
||||
const addedMigrations = migrationChanges.filter(
|
||||
(change) => change.status === 'A' || change.status === '??',
|
||||
)
|
||||
if (deletedMigrations.length > 0 && addedMigrations.length === 0) {
|
||||
failures.push(
|
||||
`删除历史迁移必须同时新增兜底迁移: ${deletedMigrations
|
||||
.map((change) => change.file)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 claimSkinRouter from './routes/claim-skin.js'
|
||||
import douyinMashidaRouter from './routes/douyin-mashida.js'
|
||||
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
||||
import open91Router from './routes/open-91.js'
|
||||
@@ -111,8 +110,6 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
||||
app.use('/api/v1/open/91', open91Router)
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/redeem', redeemRouter)
|
||||
// 皮肤领取页(路径式,公开):必须挂在 /api/v1/claim 之前,避免前缀歧义。
|
||||
app.use('/api/v1/claim-skin', claimSkinRouter)
|
||||
app.use('/api/v1/claim', claimsRouter)
|
||||
app.use('/api/v1/collect', collectRouter)
|
||||
app.use('/api/v1/pdd/extension', pddExtensionRouter)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 096_fulfillment_tasks_claim_submission.sql —— 皮肤自动发货「买家领取页」提交内容落库。
|
||||
--
|
||||
-- 背景:抖音码速达的皮肤类商品改为「自动发货 + 领取链接」,买家在 /claim-skin/:token
|
||||
-- 提交游戏账号后,我们要把账号存下来交给运营发货(并防重复提交)。
|
||||
--
|
||||
-- 为什么单开一列而不是塞进 work_orders.material_json:
|
||||
-- material_json 是发给打手的工单素材(还挂了搜索索引与统计),买家游戏账号属于隐私数据,
|
||||
-- 写进去等于对外泄露(仓库既有隐私红线,见 douyin-mashida/order-mapper.ts 的 buyerWords 注释)。
|
||||
-- fulfillment_tasks 只被履约/后台读取,工单侧(worker-platform)明确不 JOIN 该表,
|
||||
-- 因此把账号留在任务自身上是最小暴露面的做法。
|
||||
--
|
||||
-- 幂等:全部 IF NOT EXISTS。
|
||||
|
||||
ALTER TABLE fulfillment_tasks
|
||||
-- 买家领取页提交内容:{"gameAccount":"...","submittedAt":"...","source":"claim_skin"};
|
||||
-- 空对象 {} 表示尚未提交(待领取)。
|
||||
ADD COLUMN IF NOT EXISTS claim_submission_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN fulfillment_tasks.claim_submission_json IS
|
||||
'买家领取页提交内容(gameAccount/submittedAt);隐私字段,禁止写入 work_orders.material_json';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 097_drop_claim_submission.sql —— 撤销上一轮「抖音皮肤独立领取流程」留下的买家提交列。
|
||||
--
|
||||
-- 背景(设计纠偏):抖音码速达皮肤/礼包类订单的交付改为**完全复用现成的 affiliate_dash 通道**
|
||||
-- (一条链接 → 买家输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成),
|
||||
-- 上一轮另建的 `douyin_mashida_skin` 执行器 / `/api/v1/claim-skin` 接口 / 独立领取页
|
||||
-- 以及「只提交账号就结束」的 `claim_submission_json`(迁移 096)整条链路作废并删除。
|
||||
-- 买家提交的游戏账号改由 affiliate_dash 领取流程的 `claimIdentity`(expectedUid/boundUid)承载。
|
||||
--
|
||||
-- 幂等:IF EXISTS;列不存在(未应用过 096 的环境)也不会报错。
|
||||
|
||||
ALTER TABLE fulfillment_tasks DROP COLUMN IF EXISTS claim_submission_json;
|
||||
@@ -199,12 +199,11 @@ export async function updateTask(
|
||||
attempt_count = $10,
|
||||
last_error = $11,
|
||||
context_json = $12::jsonb,
|
||||
claim_submission_json = $13::jsonb,
|
||||
claimed_at = $14,
|
||||
role_confirmed_at = $15,
|
||||
redeemed_at = $16,
|
||||
updated_at = $17
|
||||
WHERE id = $18
|
||||
claimed_at = $13,
|
||||
role_confirmed_at = $14,
|
||||
redeemed_at = $15,
|
||||
updated_at = $16
|
||||
WHERE id = $17
|
||||
`,
|
||||
[
|
||||
next.task_status,
|
||||
@@ -219,7 +218,6 @@ export async function updateTask(
|
||||
next.attempt_count || 0,
|
||||
next.last_error || '',
|
||||
next.context_json || '{}',
|
||||
next.claim_submission_json || '{}',
|
||||
next.claimed_at || null,
|
||||
next.role_confirmed_at || null,
|
||||
next.redeemed_at || null,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import { getSkinClaimDetail, submitSkinClaim } from '../services/claim/claim-skin-service.js'
|
||||
import { buildMessageSuccessPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达「皮肤自动发货」买家领取页(公开接口,无需登录)。
|
||||
*
|
||||
* 响应契约(前端并行开发中):
|
||||
* - 成功:`{ code: 0, message, data }`
|
||||
* - 失败:仓库标准错误体 `{ code: 1, msg, message, errorCode, time, data: null }`,
|
||||
* 前端按 `errorCode` 展示中文提示(`claim_token_not_found` / `claim_token_expired` /
|
||||
* `claim_task_closed` / `claim_skin_task_invalid` / `claim_skin_game_account_*`)。
|
||||
*
|
||||
* 限流与 `routes/claims.ts` 同款:按客户端 IP + token 分桶。
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
const claimSkinReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claimSkin:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
const claimSkinWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claimSkin:write',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.get('/:token', claimSkinReadRateLimit, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getSkinClaimDetail(req.params.token)
|
||||
res.json(buildMessageSuccessPayload(data, 'success'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询领取详情失败', '[claim-skin/:token]', { messageAlias: true })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:token', claimSkinWriteRateLimit, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = (req.body || {}) as Record<string, unknown>
|
||||
const data = await submitSkinClaim(req.params.token, { gameAccount: body.gameAccount })
|
||||
res.json(buildMessageSuccessPayload(data, '领取成功'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '领取失败,请稍后重试', '[claim-skin/:token]', {
|
||||
messageAlias: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,6 +1,5 @@
|
||||
import { buildClaimUrl, buildSkinClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js'
|
||||
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
|
||||
import { parseSkinClaimSubmission } from '../claim/claim-skin-service.js'
|
||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||
import {
|
||||
listOrderItemsByOrderId,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
} from '../../domain/task-status.js'
|
||||
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
|
||||
import { getDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
|
||||
import { isDouyinMashidaSkinExecutor } from '../fulfillment/executors/types.js'
|
||||
import { isDouyinMashidaProvider } from '../platforms/douyin-mashida/order-mapper.js'
|
||||
import {
|
||||
mergeAffiliateDashSkuMapping,
|
||||
resolveAffiliateDashDisplayProductName,
|
||||
@@ -81,7 +80,7 @@ import type {
|
||||
AdminTaskListQueryInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../types/admin/read-inputs.js'
|
||||
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
|
||||
import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
export async function getAdminOrders(
|
||||
query: AdminOrderListQueryInput = {},
|
||||
@@ -310,11 +309,10 @@ export async function getAdminTaskDetail(
|
||||
const taskContext = parseTaskContext(task)
|
||||
const taskState = parseTaskState(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
// 统一对外领取入口为本站领取链接;平台 H5 仅作内部上下文。
|
||||
// ⚠️ 皮肤自动发货走**路径式**领取页(`<claimLinkBase>/<token>`):
|
||||
// 全局 buildClaimUrl 用的是 orders.claimBaseUrl(默认 hash 形式 `…/#/claim`),
|
||||
// 那是**打手**领取页;皮肤任务若用它,运营复制给买家会把人带进绑定 UID 的打手流程。
|
||||
const claimUrl = resolveAdminTaskClaimUrl(task, claimToken)
|
||||
// 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key):
|
||||
// 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程);
|
||||
// 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。
|
||||
const claimUrl = resolveAdminTaskClaimUrl(claimToken, order)
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||
const kuaishouCloudFulfillment =
|
||||
@@ -390,11 +388,6 @@ export async function getAdminTaskDetail(
|
||||
claimUrl,
|
||||
}
|
||||
: null,
|
||||
// 皮肤自动发货:买家在领取页提交的游戏账号。运营要靠它发货,但属买家隐私,
|
||||
// 因此与 claimToken 的明文一样按 canViewSensitiveTaskData 门禁下发。
|
||||
claimSubmission: viewerContext.canViewSensitiveTaskData
|
||||
? mapClaimSubmissionForAdmin(task)
|
||||
: null,
|
||||
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||
screenshotUrl,
|
||||
review: {
|
||||
@@ -443,48 +436,32 @@ export async function getAdminTaskDetail(
|
||||
/**
|
||||
* 后台展示的领取链接。
|
||||
*
|
||||
* - 皮肤自动发货(`douyin_mashida_skin`):`claimLinkBase` + token(路径式,买家领取页);
|
||||
* 未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。
|
||||
* - 其它执行器:沿用全局 `buildClaimUrl`(hash 打手领取页),历史行为不变。
|
||||
* - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页,
|
||||
* 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。
|
||||
* - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。
|
||||
*
|
||||
* ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道,
|
||||
* 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。
|
||||
*/
|
||||
function resolveAdminTaskClaimUrl(
|
||||
task: Pick<TaskRow, 'executor_key'>,
|
||||
claimToken: { token: string } | null | undefined,
|
||||
order: Pick<OrderRow, 'provider'> | null | undefined,
|
||||
): string {
|
||||
const token = String(claimToken?.token || '').trim()
|
||||
if (!token) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDouyinMashidaSkinExecutor(task.executor_key)) {
|
||||
const skinUrl = buildSkinClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token)
|
||||
if (skinUrl) {
|
||||
return skinUrl
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
}
|
||||
|
||||
return buildClaimUrl(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 皮肤自动发货买家提交内容(游戏账号)。
|
||||
*
|
||||
* 只回传发货必需的字段;无提交(尚未领取)返回 null,前端据此区分「待领取」。
|
||||
* 敏感数据,调用方必须先判 `canViewSensitiveTaskData`。
|
||||
*/
|
||||
function mapClaimSubmissionForAdmin(task: Partial<TaskRow>): JsonRecord | null {
|
||||
const submission = parseSkinClaimSubmission(task)
|
||||
const gameAccount = String(submission.gameAccount || '').trim()
|
||||
if (!gameAccount) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
gameAccount,
|
||||
submittedAt: String(submission.submittedAt || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
|
||||
@@ -41,11 +41,11 @@ function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) {
|
||||
secretMasked: maskSecret(config.secret),
|
||||
hasSecret: Boolean(config.secret),
|
||||
certLink: config.certLink,
|
||||
/** 皮肤自动发货领取页基址(路径式,如 https://x.ngrok-free.app/claim) */
|
||||
/** 买家领取页基址(affiliate-dash 领取流程,如 https://<域名>/claim) */
|
||||
claimLinkBase: config.claimLinkBase,
|
||||
/** 皮肤自动发货「付款后发消息」模板,占位符 {link}/{sku}/{orderNo}/{productTitle} */
|
||||
/** 付款后发领取链接的 message 模板,占位符 {link}/{sku}/{orderNo}/{productTitle} */
|
||||
messageTemplate: config.messageTemplate,
|
||||
/** 皮肤自动发货「付款后发消息」开关 */
|
||||
/** 「付款后发领取链接」开关 */
|
||||
claimMessageEnabled: config.claimMessageEnabled,
|
||||
messageType: config.messageType,
|
||||
afterVerifyLink: config.afterVerifyLink,
|
||||
@@ -54,7 +54,7 @@ function mapAdminDouyinMashidaConfig(config: DouyinMashidaSourceConfig) {
|
||||
handledTopics: config.handledTopics,
|
||||
/**
|
||||
* 联调自查:uid / secret / 兑换链接 三项都齐了才算配置完整。
|
||||
* 语义保持不变(不因皮肤新字段变化),避免影响既有脚本与面板判断。
|
||||
* 语义保持不变(不因领取链接新字段变化),避免影响既有脚本与面板判断。
|
||||
*/
|
||||
ready: Boolean(config.enabled && config.uid && config.secret && config.certLink),
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import {
|
||||
} from '../../../repositories/claim-token-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { createTaskClaimToken, buildClaimUrl } from '../../claim/claim-service.js'
|
||||
import { resolveDouyinMashidaSkinClaimUrl } from '../../platforms/douyin-mashida/claim-link.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import {
|
||||
createTaskClaimToken,
|
||||
buildClaimUrl,
|
||||
buildConfiguredClaimUrl,
|
||||
} from '../../claim/claim-service.js'
|
||||
import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js'
|
||||
import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.js'
|
||||
import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js'
|
||||
import { preparePaidFulfillmentTask } from '../../fulfillment/executors/registry.js'
|
||||
import {
|
||||
@@ -13,10 +19,7 @@ import {
|
||||
canRegenerateClaimLinkStatus,
|
||||
TASK_STATUS,
|
||||
} from '../../../domain/task-status.js'
|
||||
import {
|
||||
isDouyinMashidaSkinExecutor,
|
||||
isRetiredFulfillmentExecutor,
|
||||
} from '../../fulfillment/executors/types.js'
|
||||
import { isRetiredFulfillmentExecutor } from '../../fulfillment/executors/types.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import {
|
||||
@@ -38,6 +41,7 @@ import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from '../../../types/admin/write-models.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
export async function closeAdminTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
@@ -226,7 +230,7 @@ export async function retryAdminTask(
|
||||
}
|
||||
|
||||
if (claimToken) {
|
||||
response.claimUrl = resolveAdminTaskClaimUrl(effectiveTask, claimToken)
|
||||
response.claimUrl = await resolveAdminTaskClaimUrl(effectiveTask, claimToken)
|
||||
response.token = claimToken
|
||||
}
|
||||
|
||||
@@ -234,15 +238,32 @@ export async function retryAdminTask(
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台回显的领取链接口径:
|
||||
* - 皮肤自动发货任务:路径式皮肤领取链接(`claimLinkBase` + token),**不能**用 hash 版通用领取页;
|
||||
* 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key):
|
||||
* - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程);
|
||||
* 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空;
|
||||
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
|
||||
*/
|
||||
function resolveAdminTaskClaimUrl(task: { executor_key?: unknown }, token: string): string {
|
||||
if (isDouyinMashidaSkinExecutor(task.executor_key)) {
|
||||
return resolveDouyinMashidaSkinClaimUrl(token)
|
||||
async function resolveAdminTaskClaimUrl(
|
||||
task: Pick<TaskRow, 'order_id'>,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) {
|
||||
return ''
|
||||
}
|
||||
return buildClaimUrl(token)
|
||||
|
||||
const order = await getOrderById(task.order_id)
|
||||
if (isDouyinMashidaProvider(order?.provider)) {
|
||||
const claimUrl = buildConfiguredClaimUrl(
|
||||
getDouyinMashidaSourceConfig().claimLinkBase,
|
||||
normalizedToken,
|
||||
)
|
||||
if (claimUrl) {
|
||||
return claimUrl
|
||||
}
|
||||
}
|
||||
|
||||
return buildClaimUrl(normalizedToken)
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(
|
||||
@@ -323,7 +344,7 @@ export async function regenerateAdminTaskClaimLink(
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: resolveAdminTaskClaimUrl(updatedTask, claimToken.token),
|
||||
claimUrl: await resolveAdminTaskClaimUrl(updatedTask, claimToken.token),
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,6 @@ const CORE_PROFILES: CoreProfile[] = [
|
||||
inventoryStrategy: 'external_platform',
|
||||
requirements: [],
|
||||
},
|
||||
{
|
||||
// 抖音码速达皮肤自动发货:不打外部平台,只发本站领取链接,等买家提交游戏账号后人工发货。
|
||||
// profile_key 与 executor_key 同名,planner 按 executorKey 取 profile。
|
||||
profileKey: 'douyin_mashida_skin',
|
||||
name: '抖音皮肤自动发货',
|
||||
executorKey: 'douyin_mashida_skin',
|
||||
requiresClaim: true,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'static_pool',
|
||||
requirements: [],
|
||||
},
|
||||
]
|
||||
|
||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
douyinMashidaSkinExecutor,
|
||||
resolveSkinTaskClaimToken,
|
||||
} from './douyin-mashida-skin-executor.js'
|
||||
import { getFulfillmentExecutor } from './registry.js'
|
||||
import type { FulfillmentPrepareDeps } from './types.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
const FIXED_NOW = '2026-10-10T00:00:00.000Z'
|
||||
|
||||
function createTask(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||
return {
|
||||
id: 9,
|
||||
order_id: 1,
|
||||
order_item_id: 11,
|
||||
unit_index: 1,
|
||||
platform_order_id: '6930318258716179555',
|
||||
profile_id: 3,
|
||||
task_no: 'DT-1',
|
||||
executor_key: 'douyin_mashida_skin',
|
||||
task_status: 'paid',
|
||||
delivery_status: 'pending',
|
||||
result_code: '',
|
||||
result_message: '',
|
||||
automation_mode: 'manual',
|
||||
requires_claim: true,
|
||||
user_action_status: 'pending_claim',
|
||||
attempt_count: 0,
|
||||
runtime_session_id: '',
|
||||
login_type: '',
|
||||
nickname: '',
|
||||
role_name: '',
|
||||
role_id: '',
|
||||
area: '',
|
||||
partition_name: '',
|
||||
claim_token: '',
|
||||
claim_expires_at: null,
|
||||
primary_claim_token: '',
|
||||
primary_claim_token_id: null,
|
||||
primary_claim_token_status: '',
|
||||
primary_claim_expires_at: null,
|
||||
artifacts_json: {},
|
||||
context_json: {},
|
||||
screenshot_path: '',
|
||||
last_error: '',
|
||||
retry_count: 0,
|
||||
created_at: FIXED_NOW,
|
||||
updated_at: FIXED_NOW,
|
||||
claimed_at: null,
|
||||
role_confirmed_at: null,
|
||||
redeemed_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createDeps(overrides: Partial<FulfillmentPrepareDeps> = {}) {
|
||||
const updates: Array<{ taskId: number | string; patch: Record<string, unknown> }> = []
|
||||
const tokenRequests: Array<number | string> = []
|
||||
|
||||
const deps: FulfillmentPrepareDeps = {
|
||||
updateTask: async (taskId, patch) => {
|
||||
updates.push({ taskId, patch: { ...patch } })
|
||||
return { ...createTask(), ...patch }
|
||||
},
|
||||
createTaskClaimToken: async (taskId) => {
|
||||
tokenRequests.push(taskId)
|
||||
return { token: 'skin-token', expired_at: null }
|
||||
},
|
||||
notifyTaskAutoManualReview: async () => null,
|
||||
nowIso: () => FIXED_NOW,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return { deps, updates, tokenRequests }
|
||||
}
|
||||
|
||||
test('皮肤执行器已注册到 registry', () => {
|
||||
assert.equal(getFulfillmentExecutor('douyin_mashida_skin'), douyinMashidaSkinExecutor)
|
||||
})
|
||||
|
||||
test('preparePaidTask:无 token 时生成 claim token 并置为待领取', async () => {
|
||||
const { deps, updates, tokenRequests } = createDeps()
|
||||
|
||||
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(createTask(), deps)
|
||||
|
||||
assert.deepEqual(tokenRequests, [9])
|
||||
assert.equal(updates.length, 2)
|
||||
assert.deepEqual(updates[0]?.patch, {
|
||||
claim_token: 'skin-token',
|
||||
claim_expires_at: null,
|
||||
updated_at: FIXED_NOW,
|
||||
})
|
||||
assert.deepEqual(updates[1]?.patch, {
|
||||
task_status: 'link_generated',
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: '',
|
||||
updated_at: FIXED_NOW,
|
||||
})
|
||||
assert.equal(result?.task_status, 'link_generated')
|
||||
})
|
||||
|
||||
test('preparePaidTask:已有 token 且已是待领取时完全幂等(不重复建 token)', async () => {
|
||||
const { deps, updates, tokenRequests } = createDeps()
|
||||
|
||||
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(
|
||||
createTask({
|
||||
claim_token: 'skin-token',
|
||||
primary_claim_token: 'skin-token',
|
||||
primary_claim_token_id: 5,
|
||||
task_status: 'link_generated',
|
||||
user_action_status: 'pending_claim',
|
||||
}),
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.deepEqual(tokenRequests, [])
|
||||
assert.deepEqual(updates, [])
|
||||
assert.equal(result?.task_status, 'link_generated')
|
||||
})
|
||||
|
||||
test('preparePaidTask:已有 token 但状态未就绪时只补状态', async () => {
|
||||
const { deps, updates, tokenRequests } = createDeps()
|
||||
|
||||
await douyinMashidaSkinExecutor.preparePaidTask?.(
|
||||
createTask({ claim_token: 'skin-token', primary_claim_token: 'skin-token' }),
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.deepEqual(tokenRequests, [])
|
||||
assert.equal(updates.length, 1)
|
||||
assert.equal(updates[0]?.patch.task_status, 'link_generated')
|
||||
})
|
||||
|
||||
test('preparePaidTask:买家已领取的任务不会被降级回待领取', async () => {
|
||||
const { deps, updates, tokenRequests } = createDeps()
|
||||
|
||||
const claimed = createTask({
|
||||
claim_token: 'skin-token',
|
||||
primary_claim_token: 'skin-token',
|
||||
task_status: 'claimed',
|
||||
user_action_status: 'not_required',
|
||||
})
|
||||
const result = await douyinMashidaSkinExecutor.preparePaidTask?.(claimed, deps)
|
||||
|
||||
assert.deepEqual(tokenRequests, [])
|
||||
assert.deepEqual(updates, [])
|
||||
assert.equal(result?.task_status, 'claimed')
|
||||
})
|
||||
|
||||
test('resolveSkinTaskClaimToken 优先 primary_claim_token', () => {
|
||||
assert.equal(resolveSkinTaskClaimToken({ claim_token: 'a' }), 'a')
|
||||
assert.equal(resolveSkinTaskClaimToken({ claim_token: 'a', primary_claim_token: 'b' }), 'b')
|
||||
assert.equal(resolveSkinTaskClaimToken(null), '')
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { resolveDouyinMashidaSkinClaimUrl } from '../../platforms/douyin-mashida/claim-link.js'
|
||||
import type { DouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js'
|
||||
import {
|
||||
FULFILLMENT_EXECUTOR_KEYS,
|
||||
type FulfillmentDeliveryLink,
|
||||
type FulfillmentExecutor,
|
||||
type FulfillmentPrepareDeps,
|
||||
} from './types.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达「皮肤自动发货」执行器。
|
||||
*
|
||||
* 与 affiliate-dash 执行器的最大区别:**不打任何外部平台**。
|
||||
* 付款后我们只需要「确保任务有 claim token」并把任务置为待领取(`link_generated` + `pending_claim`),
|
||||
* 领取链接(`claimLinkBase/token`)由调用方拿去做两件事:
|
||||
* 1. 写进 `coupons.syncV2` 的 `cert_link`(见 redeem/certificate-service.ts);
|
||||
* 2. 通过 `order.message` 按模板发给买家(见 douyin-mashida/notify-service.ts)。
|
||||
*/
|
||||
export const douyinMashidaSkinExecutor: FulfillmentExecutor = {
|
||||
key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
preparePaidTask,
|
||||
resolveDeliveryLink,
|
||||
}
|
||||
|
||||
export function resolveSkinTaskClaimToken(task: Partial<TaskRow> | null | undefined): string {
|
||||
return String(task?.primary_claim_token || task?.claim_token || '').trim()
|
||||
}
|
||||
|
||||
export type OrderItemSkinClaimLink = {
|
||||
/** 领取链接;未配置 claimLinkBase、或该订单项没有皮肤任务/token 时为空串 */
|
||||
claimUrl: string
|
||||
token: string
|
||||
/** 该订单项下的皮肤履约单元数量(数量 > 1 时会有多个 token) */
|
||||
taskCount: number
|
||||
pickedTaskId: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某订单项的皮肤领取链接。
|
||||
*
|
||||
* 一个订单项按数量会拆成 N 个履约单元(`fulfillment_tasks`),每个单元一个 token
|
||||
* (`claim_tokens.task_id` 唯一);这里固定取 task id 最小的那个,保证同一张券每次解析结果一致。
|
||||
* 调用方(certificate-service / notify-service)据 `taskCount` 打 warn 留痕。
|
||||
*/
|
||||
export async function resolveOrderItemSkinClaimLink(input: {
|
||||
orderId: number | string
|
||||
orderItemId: number | string
|
||||
config?: DouyinMashidaSourceConfig | undefined
|
||||
}): Promise<OrderItemSkinClaimLink> {
|
||||
const skinTasks = (await listTasksByOrderId(input.orderId))
|
||||
.filter(
|
||||
(task) =>
|
||||
Number(task.order_item_id) === Number(input.orderItemId) &&
|
||||
String(task.executor_key || '').trim() === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
)
|
||||
.sort((left, right) => Number(left.id) - Number(right.id))
|
||||
|
||||
const pickedTask = skinTasks[0] || null
|
||||
const token = resolveSkinTaskClaimToken(pickedTask)
|
||||
|
||||
return {
|
||||
claimUrl: resolveDouyinMashidaSkinClaimUrl(token, input.config),
|
||||
token,
|
||||
taskCount: skinTasks.length,
|
||||
pickedTaskId: pickedTask ? Number(pickedTask.id) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePaidTask(
|
||||
task: TaskRow,
|
||||
deps: FulfillmentPrepareDeps,
|
||||
): Promise<TaskRow | null> {
|
||||
let workingTask = task
|
||||
|
||||
// 幂等:已有 token 不重复生成(claim_tokens.task_id 唯一,重复插入只会覆盖)。
|
||||
if (!resolveSkinTaskClaimToken(task)) {
|
||||
const claimToken = await deps.createTaskClaimToken(task.id)
|
||||
workingTask =
|
||||
(await deps.updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
updated_at: deps.nowIso(),
|
||||
})) || task
|
||||
}
|
||||
|
||||
// 买家已提交过(claimed)的任务不允许被降级回待领取。
|
||||
if (
|
||||
workingTask.task_status === TASK_STATUS.CLAIMED ||
|
||||
workingTask.task_status === TASK_STATUS.REDEEMED ||
|
||||
workingTask.task_status === TASK_STATUS.COMPLETED
|
||||
) {
|
||||
return workingTask
|
||||
}
|
||||
|
||||
if (
|
||||
workingTask.task_status !== TASK_STATUS.LINK_GENERATED ||
|
||||
workingTask.user_action_status !== 'pending_claim'
|
||||
) {
|
||||
workingTask =
|
||||
(await deps.updateTask(task.id, {
|
||||
task_status: TASK_STATUS.LINK_GENERATED,
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: '',
|
||||
updated_at: deps.nowIso(),
|
||||
})) || workingTask
|
||||
}
|
||||
|
||||
return workingTask
|
||||
}
|
||||
|
||||
/** 对外统一返回本站皮肤领取链接;基址未配置时返回 null(不猜、不回退 hash 版领取页)。 */
|
||||
async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLink | null> {
|
||||
const token = resolveSkinTaskClaimToken(task)
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimUrl = resolveDouyinMashidaSkinClaimUrl(token)
|
||||
if (!claimUrl) {
|
||||
logWarn('[douyin-mashida/skin]', '未配置领取链接基址(claimLinkBase),无法生成皮肤领取链接', {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
platformOrderId: task.platform_order_id,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim()
|
||||
return expireTime ? { claimUrl, expireTime } : { claimUrl }
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isPaidPreparationStableStatus } from '../../../domain/task-status.js'
|
||||
import { affiliateDashExecutor } from './affiliate-dash-executor.js'
|
||||
import { douyinMashidaSkinExecutor } from './douyin-mashida-skin-executor.js'
|
||||
import { legacyFeifeiExecutor, legacyLewanExecutor } from './legacy-retired-executor.js'
|
||||
import { manualDispatchExecutor } from './manual-executor.js'
|
||||
import {
|
||||
@@ -15,7 +14,6 @@ import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
const EXECUTORS = new Map<string, FulfillmentExecutor>([
|
||||
[FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, affiliateDashExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN, douyinMashidaSkinExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, legacyLewanExecutor],
|
||||
[FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, legacyFeifeiExecutor],
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
export const FULFILLMENT_EXECUTOR_KEYS = {
|
||||
MANUAL_DISPATCH: 'manual_dispatch',
|
||||
AFFILIATE_DASH: 'affiliate_dash',
|
||||
/** 抖音码速达「皮肤自动发货」:只发本站领取链接,不打 affiliate-dash 平台 */
|
||||
DOUYIN_MASHIDA_SKIN: 'douyin_mashida_skin',
|
||||
/** @deprecated 已下线,仅历史任务 / stub 识别 */
|
||||
KUAISHOU_CLOUD: 'kuaishou_ct_assisted',
|
||||
/** @deprecated 已下线,仅历史任务识别 */
|
||||
@@ -70,10 +68,6 @@ export function isManualDispatchExecutor(value: unknown): boolean {
|
||||
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
|
||||
}
|
||||
|
||||
export function isDouyinMashidaSkinExecutor(value: unknown): boolean {
|
||||
return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN
|
||||
}
|
||||
|
||||
export function isRetiredFulfillmentExecutor(value: unknown): boolean {
|
||||
const executorKey = normalizeExecutorKey(value)
|
||||
return (
|
||||
|
||||
@@ -109,25 +109,26 @@ test('planFulfillmentTaskForOrderItem affiliate 缺 sku 时返回 null', async (
|
||||
assert.equal(plan, null)
|
||||
})
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 为抖音皮肤商品生成领取链接履约计划', async () => {
|
||||
test('planFulfillmentTaskForOrderItem 为抖音命中商品生成 affiliate_dash 履约计划', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder({ provider: 'douyin_mashida', platform: 'doudian' }),
|
||||
item: createOrderItem({
|
||||
sku_code: '幸运币2个',
|
||||
sku_name: '【测试专用】和平精英皮肤小店入口',
|
||||
item_snapshot_json: {
|
||||
fulfillmentRoute: { selectedExecutorKey: 'douyin_mashida_skin' },
|
||||
douyinSkin: {
|
||||
internalSku: '幸运币2个',
|
||||
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
|
||||
affiliateDash: {
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
productNo: '幸运币2个',
|
||||
sku: 'lucky_coin_x2',
|
||||
productTitle: '【测试专用】和平精英皮肤小店入口',
|
||||
skuName: '幸运币2个',
|
||||
},
|
||||
},
|
||||
}),
|
||||
getProfileByKey: async (profileKey) => ({
|
||||
id: 21,
|
||||
profile_key: profileKey,
|
||||
name: '抖音皮肤自动发货',
|
||||
name: 'affiliate-dash 履约',
|
||||
executor_key: profileKey,
|
||||
requires_claim: false,
|
||||
auto_dispatch: false,
|
||||
@@ -135,33 +136,31 @@ test('planFulfillmentTaskForOrderItem 为抖音皮肤商品生成领取链接履
|
||||
})
|
||||
|
||||
assert.ok(plan)
|
||||
assert.equal(plan.executorKey, 'douyin_mashida_skin')
|
||||
assert.equal(plan.profileKey, 'douyin_mashida_skin')
|
||||
assert.equal(plan.executorKey, 'affiliate_dash')
|
||||
assert.equal(plan.profileKey, 'affiliate_dash')
|
||||
assert.equal(plan.requiresClaim, true)
|
||||
assert.equal(plan.autoDispatch, false)
|
||||
assert.equal(plan.context.affiliateDash, null)
|
||||
|
||||
const douyinSkin = plan.context.douyinSkin as Record<string, unknown> | null
|
||||
assert.ok(douyinSkin)
|
||||
assert.equal(douyinSkin.internalSku, '幸运币2个')
|
||||
assert.equal(douyinSkin.sku, 'lucky_coin_x2')
|
||||
assert.equal(douyinSkin.imageUrl, '/images/products/lucky_coin_x2.webp')
|
||||
const affiliateDash = plan.context.affiliateDash as Record<string, unknown> | null
|
||||
assert.ok(affiliateDash)
|
||||
assert.equal(affiliateDash.sku, 'lucky_coin_x2')
|
||||
assert.equal(affiliateDash.productName, '幸运币2个')
|
||||
})
|
||||
|
||||
test('planFulfillmentTaskForOrderItem 抖音皮肤缺内部 SKU 时返回 null', async () => {
|
||||
test('planFulfillmentTaskForOrderItem 抖音订单缺 affiliate_dash sku 时返回 null', async () => {
|
||||
const plan = await planFulfillmentTaskForOrderItem({
|
||||
order: createOrder({ provider: 'douyin_mashida', platform: 'doudian' }),
|
||||
item: createOrderItem({
|
||||
sku_code: '',
|
||||
sku_name: '无内部 SKU',
|
||||
item_snapshot_json: {
|
||||
fulfillmentRoute: { selectedExecutorKey: 'douyin_mashida_skin' },
|
||||
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
|
||||
},
|
||||
}),
|
||||
getProfileByKey: async (profileKey) => ({
|
||||
id: 21,
|
||||
profile_key: profileKey,
|
||||
name: '抖音皮肤自动发货',
|
||||
name: 'affiliate-dash 履约',
|
||||
executor_key: profileKey,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import {
|
||||
FULFILLMENT_EXECUTOR_KEYS,
|
||||
isAffiliateDashExecutor,
|
||||
isDouyinMashidaSkinExecutor,
|
||||
} from './executors/types.js'
|
||||
import { FULFILLMENT_EXECUTOR_KEYS, isAffiliateDashExecutor } from './executors/types.js'
|
||||
|
||||
export type FulfillmentBindingLike = {
|
||||
id: number
|
||||
@@ -96,7 +91,6 @@ function buildFulfillmentTaskContext({
|
||||
fulfillmentConfig: JsonObject
|
||||
}) {
|
||||
const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash)
|
||||
const douyinSkinConfig = parseJsonObject(fulfillmentConfig.douyinSkin)
|
||||
|
||||
return {
|
||||
profileKey,
|
||||
@@ -128,17 +122,6 @@ function buildFulfillmentTaskContext({
|
||||
lastSyncedAt: null,
|
||||
}
|
||||
: null,
|
||||
// 皮肤自动发货:任务上下文只放「怎么发货」的静态信息;买家提交的游戏账号
|
||||
// 属于隐私数据,落在 fulfillment_tasks.claim_submission_json,不进这里、更不进打手工单。
|
||||
douyinSkin: isDouyinMashidaSkinExecutor(executorKey)
|
||||
? {
|
||||
flowType: 'douyin_mashida_skin',
|
||||
internalSku: String(douyinSkinConfig.internalSku || item.sku_code || '').trim(),
|
||||
sku: String(douyinSkinConfig.sku || '').trim(),
|
||||
productTitle: String(douyinSkinConfig.productTitle || item.sku_name || '').trim(),
|
||||
imageUrl: resolveProductImageUrl(douyinSkinConfig.internalSku || item.sku_code),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +134,6 @@ async function resolveDynamicFulfillmentProfile(
|
||||
return resolveDynamicAffiliateDashProfile(item, getProfileByKey)
|
||||
}
|
||||
|
||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN) {
|
||||
return resolveDynamicDouyinMashidaSkinProfile(item, getProfileByKey)
|
||||
}
|
||||
|
||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
|
||||
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
|
||||
}
|
||||
@@ -202,46 +181,8 @@ async function resolveDynamicAffiliateDashProfile(
|
||||
}
|
||||
|
||||
/**
|
||||
* 皮肤自动发货:内部 SKU 命中权威映射表时使用。
|
||||
* `requires_claim=true` 让任务要求领取链接;`auto_dispatch=false` 表示不需要打手抢单。
|
||||
* 人工履约:路由规则明确指定人工、或未命中任何自动通道时使用。
|
||||
*/
|
||||
async function resolveDynamicDouyinMashidaSkinProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: FulfillmentProfileResolver,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const douyinSkin = parseJsonObject(snapshot.douyinSkin)
|
||||
const internalSku = String(douyinSkin.internalSku || item.sku_code || '').trim()
|
||||
if (!internalSku) {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN)
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||
profile_key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
profile_name: String(profile.profile_name || profile.name || '抖音皮肤自动发货').trim(),
|
||||
executor_key: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
requires_claim: true,
|
||||
auto_dispatch: false,
|
||||
config_json: {
|
||||
flowType: 'douyin_mashida_skin',
|
||||
configId: `${FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN}:${internalSku}`,
|
||||
douyinSkin: {
|
||||
internalSku,
|
||||
sku: String(douyinSkin.sku || '').trim(),
|
||||
productTitle: String(douyinSkin.productTitle || item.sku_name || '').trim(),
|
||||
},
|
||||
notes: '抖音码速达皮肤商品自动发货(本站领取链接)',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDynamicManualDispatchProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: FulfillmentProfileResolver,
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
import {
|
||||
matchAffiliateDashSku,
|
||||
matchAffiliateDashSkuPassthrough,
|
||||
matchDouyinMashidaSkinSku,
|
||||
matchDouyinMashidaAffiliateDashSku,
|
||||
normalizeAffiliateDashMappingKey,
|
||||
resolveOrderItemForFulfillment,
|
||||
} from './product-resolution-service.js'
|
||||
@@ -100,7 +100,7 @@ test('reverseAffiliateDashSkuToProductNo 未命中返回空串', () => {
|
||||
assert.equal(reverseAffiliateDashSkuToProductNo(''), '')
|
||||
})
|
||||
|
||||
// ---- 抖音码速达(抖店)皮肤商品分流 ----
|
||||
// ---- 抖音码速达(抖店)订单分流:复用 affiliate_dash 通道 ----
|
||||
|
||||
const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用'
|
||||
|
||||
@@ -126,14 +126,14 @@ function selectedExecutorKeyOf(
|
||||
return String(route?.selectedExecutorKey || '')
|
||||
}
|
||||
|
||||
test('matchDouyinMashidaSkinSku 只认权威映射表并做归一化', () => {
|
||||
assert.equal(matchDouyinMashidaSkinSku('幸运币2个'), 'lucky_coin_x2')
|
||||
assert.equal(matchDouyinMashidaSkinSku(' 幸运币2个 '), 'lucky_coin_x2')
|
||||
assert.equal(matchDouyinMashidaSkinSku('不存在的皮肤'), '')
|
||||
assert.equal(matchDouyinMashidaSkinSku(''), '')
|
||||
test('matchDouyinMashidaAffiliateDashSku 只认权威映射表并做归一化', () => {
|
||||
assert.equal(matchDouyinMashidaAffiliateDashSku('幸运币2个'), 'lucky_coin_x2')
|
||||
assert.equal(matchDouyinMashidaAffiliateDashSku(' 幸运币2个 '), 'lucky_coin_x2')
|
||||
assert.equal(matchDouyinMashidaAffiliateDashSku('不存在的皮肤'), '')
|
||||
assert.equal(matchDouyinMashidaAffiliateDashSku(''), '')
|
||||
})
|
||||
|
||||
test('抖音订单:内部 SKU 命中映射表 → 皮肤自动发货', async () => {
|
||||
test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道自动发货', async () => {
|
||||
const item = await resolveOrderItemForFulfillment({
|
||||
provider: 'douyin_mashida',
|
||||
platform: 'doudian',
|
||||
@@ -141,16 +141,18 @@ test('抖音订单:内部 SKU 命中映射表 → 皮肤自动发货', async (
|
||||
})
|
||||
|
||||
assert.equal(item.isConfigured, true)
|
||||
assert.equal(selectedExecutorKeyOf(item), 'douyin_mashida_skin')
|
||||
assert.equal(item.skuCode, '幸运币2个')
|
||||
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
|
||||
assert.deepEqual(item.snapshot.douyinSkin, {
|
||||
assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash')
|
||||
// 走 affiliate_dash 通道后与 91 同口径:skuCode 是英文 sku、skuName 是中文商品名
|
||||
assert.equal(item.skuCode, 'lucky_coin_x2')
|
||||
assert.equal(item.skuName, '幸运币2个')
|
||||
// 快照里的 affiliateDash 就是 affiliate_dash 执行器/规划器要用的映射结果
|
||||
assert.deepEqual(item.snapshot.affiliateDash, {
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
internalSku: '幸运币2个',
|
||||
productNo: '幸运币2个',
|
||||
sku: 'lucky_coin_x2',
|
||||
productTitle: DOUYIN_SKIN_TITLE,
|
||||
skuName: '幸运币2个',
|
||||
})
|
||||
assert.equal(resolveProductImageUrl(item.skuCode), '/images/products/lucky_coin_x2.webp')
|
||||
assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp')
|
||||
})
|
||||
|
||||
test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch', async () => {
|
||||
@@ -161,7 +163,7 @@ test('抖音订单:内部 SKU 未命中映射表 → 回退 manual_dispatch',
|
||||
})
|
||||
|
||||
assert.equal(selectedExecutorKeyOf(item), 'manual_dispatch')
|
||||
assert.equal(item.snapshot.douyinSkin, null)
|
||||
assert.equal(item.snapshot.affiliateDash, null)
|
||||
// 引流长标题作为 skuName,内部 SKU 作为 skuCode
|
||||
assert.equal(item.skuCode, '不存在的皮肤')
|
||||
assert.equal(item.skuName, DOUYIN_SKIN_TITLE)
|
||||
|
||||
@@ -44,14 +44,6 @@ export type AffiliateDashSkuMatch = {
|
||||
matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough'
|
||||
}
|
||||
|
||||
/** 抖音码速达「皮肤」命中信息:内部 SKU → 英文 sku(图片文件名同一套映射)。 */
|
||||
export type DouyinMashidaSkinMatch = {
|
||||
sku: string
|
||||
internalSku: string
|
||||
productTitle: string
|
||||
matchMode: 'affiliate_dash_sku_mapping'
|
||||
}
|
||||
|
||||
type FulfillmentItemCandidate = {
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
@@ -59,7 +51,6 @@ type FulfillmentItemCandidate = {
|
||||
externalSkuNameNormalized: string
|
||||
resolvedSkuCode: string
|
||||
affiliateDashMatch: AffiliateDashSkuMatch | null
|
||||
douyinSkinMatch: DouyinMashidaSkinMatch | null
|
||||
fulfillmentRoute: FulfillmentRouteResult | null
|
||||
isConfigured: boolean
|
||||
}
|
||||
@@ -91,7 +82,6 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized,
|
||||
affiliateDashMatch,
|
||||
douyinSkinMatch,
|
||||
fulfillmentRoute,
|
||||
} = candidate
|
||||
|
||||
@@ -130,14 +120,6 @@ export async function resolveOrderItemForFulfillment({
|
||||
skuName: affiliateDashMatch.skuName,
|
||||
}
|
||||
: null,
|
||||
douyinSkin: douyinSkinMatch
|
||||
? {
|
||||
matchMode: douyinSkinMatch.matchMode,
|
||||
internalSku: douyinSkinMatch.internalSku,
|
||||
sku: douyinSkinMatch.sku,
|
||||
productTitle: douyinSkinMatch.productTitle,
|
||||
}
|
||||
: null,
|
||||
isConfigured: candidate.isConfigured,
|
||||
}
|
||||
|
||||
@@ -231,7 +213,6 @@ async function resolveConfiguredItemCandidate({
|
||||
externalSkuNameNormalized,
|
||||
resolvedSkuCode,
|
||||
affiliateDashMatch,
|
||||
douyinSkinMatch: null,
|
||||
fulfillmentRoute,
|
||||
isConfigured: Boolean(selectedExecutorKey),
|
||||
}
|
||||
@@ -256,7 +237,6 @@ async function resolveConfiguredItemCandidate({
|
||||
externalSkuNameNormalized,
|
||||
resolvedSkuCode,
|
||||
affiliateDashMatch: null,
|
||||
douyinSkinMatch: null,
|
||||
fulfillmentRoute: null,
|
||||
isConfigured: false,
|
||||
}
|
||||
@@ -267,8 +247,9 @@ async function resolveConfiguredItemCandidate({
|
||||
*
|
||||
* 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品),
|
||||
* `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。
|
||||
* 因此分流与图片都按内部 SKU 走:
|
||||
* - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 皮肤自动发货(本站领取链接);
|
||||
* 因此分流都按内部 SKU 走:
|
||||
* - 内部 SKU 命中权威表 `AFFILIATE_DASH_SKU_MAPPING` → 复用现成的 `affiliate_dash` 通道
|
||||
* (买家输 UID → 二维码 → 扫码绑定 → 比对 UID → 发货完成);
|
||||
* - 未命中 → 显式映射拿不到通道,按现有语义回退 `manual_dispatch`(接单平台/人工)。
|
||||
*/
|
||||
function resolveDouyinMashidaItemCandidate({
|
||||
@@ -286,29 +267,33 @@ function resolveDouyinMashidaItemCandidate({
|
||||
}): FulfillmentItemCandidate {
|
||||
const internalSku = pickFirstNonEmpty([externalSkuCode, externalItemId])
|
||||
const productTitle = pickFirstNonEmpty([externalSkuName, internalSku])
|
||||
const skinSku = matchDouyinMashidaSkinSku(internalSku)
|
||||
const douyinSkinMatch: DouyinMashidaSkinMatch | null = skinSku
|
||||
// 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。
|
||||
// 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**:
|
||||
// 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。
|
||||
// 不另建执行器、不另做一套页面(与旧快手皮肤自动发货完全一致)。
|
||||
const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku)
|
||||
const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku
|
||||
? {
|
||||
sku: skinSku,
|
||||
internalSku,
|
||||
productTitle,
|
||||
sku: dashSku,
|
||||
productNo: internalSku,
|
||||
skuName: internalSku,
|
||||
matchMode: 'affiliate_dash_sku_mapping',
|
||||
}
|
||||
: null
|
||||
const fulfillmentRoute = resolveFulfillmentRoute({
|
||||
productName: productTitle,
|
||||
internalSku,
|
||||
// 命中皮肤 → 皮肤自动发货;未命中 → **显式**回落人工(接单平台)。
|
||||
// 命中 → 自动发货;未命中 → **显式**回落人工(接单平台)。
|
||||
// 不依赖全局 `unmatchedExecutorKey`:线上/联调环境该值可能是空串(dev 库就是空串),
|
||||
// 那样未命中的抖音订单会既不建任务、也不进人工,等于静默漏单。
|
||||
preferredExecutorKey: douyinSkinMatch
|
||||
? FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN
|
||||
preferredExecutorKey: affiliateDashMatch
|
||||
? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||
: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
candidates: [
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
available: Boolean(douyinSkinMatch),
|
||||
reason: douyinSkinMatch ? '' : '内部 SKU 未命中皮肤商品映射',
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||
available: Boolean(affiliateDashMatch),
|
||||
reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射',
|
||||
},
|
||||
{
|
||||
executorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
@@ -323,20 +308,19 @@ function resolveDouyinMashidaItemCandidate({
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized,
|
||||
// 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,领取页图片与展示都靠它。
|
||||
// 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,展示与商品图都靠它。
|
||||
resolvedSkuCode: internalSku || pickFirstNonEmpty([externalItemId, item.itemId]),
|
||||
affiliateDashMatch: null,
|
||||
douyinSkinMatch,
|
||||
affiliateDashMatch,
|
||||
fulfillmentRoute,
|
||||
isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部 SKU → 英文 sku:只查权威表(与领取页图片 `resolveProductImageUrl` 同一套来源),
|
||||
* 保证「能路由到皮肤执行器」与「能取到商品图」永远一致。
|
||||
* 内部 SKU → affiliate-dash 英文 sku:只查权威表(与商品图 `resolveProductImageUrl` 同一套来源),
|
||||
* 保证「能路由到 affiliate-dash 自动发货」与「能取到商品图」永远一致。
|
||||
*/
|
||||
export function matchDouyinMashidaSkinSku(internalSku: unknown): string {
|
||||
export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string {
|
||||
const raw = String(internalSku ?? '').trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
|
||||
@@ -12,11 +12,7 @@ test('normalizeFulfillmentRoutingConfig drops retired executors and keeps affili
|
||||
})
|
||||
|
||||
assert.deepEqual(config.defaultExecutorPriority, ['affiliate_dash'])
|
||||
assert.deepEqual(Object.keys(config.executors).sort(), [
|
||||
'affiliate_dash',
|
||||
'douyin_mashida_skin',
|
||||
'manual_dispatch',
|
||||
])
|
||||
assert.deepEqual(Object.keys(config.executors).sort(), ['affiliate_dash', 'manual_dispatch'])
|
||||
assert.equal(config.unmatchedExecutorKey, 'manual_dispatch')
|
||||
})
|
||||
|
||||
@@ -186,7 +182,7 @@ test('normalizeFulfillmentRoutingConfig 支持按内部 SKU 的规则维度', ()
|
||||
rules: [
|
||||
{
|
||||
internalSku: '幸运币2个',
|
||||
executorKey: 'douyin_mashida_skin',
|
||||
executorKey: 'affiliate_dash',
|
||||
priority: 500,
|
||||
},
|
||||
],
|
||||
@@ -201,7 +197,7 @@ test('normalizeFulfillmentRoutingConfig 支持按内部 SKU 的规则维度', ()
|
||||
assert.equal(rule.normalizedProductName, '')
|
||||
assert.equal(rule.internalSku, '幸运币2个')
|
||||
assert.equal(rule.normalizedInternalSku, '幸运币2个')
|
||||
assert.equal(rule.id, '幸运币2个:douyin_mashida_skin')
|
||||
assert.equal(rule.id, '幸运币2个:affiliate_dash')
|
||||
})
|
||||
|
||||
test('旧商品名规则归一化后带 matchField=product_name,语义不变', () => {
|
||||
@@ -221,13 +217,13 @@ test('resolveFulfillmentRoute 命中内部 SKU 规则', () => {
|
||||
const result = resolveFulfillmentRoute({
|
||||
productName: '【测试专用】和平精英皮肤小店入口',
|
||||
internalSku: '幸运币2个',
|
||||
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
|
||||
candidates: [{ executorKey: 'affiliate_dash', available: true }],
|
||||
config: normalizeFulfillmentRoutingConfig({
|
||||
rules: [{ internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 500 }],
|
||||
rules: [{ internalSku: '幸运币2个', executorKey: 'affiliate_dash', priority: 500 }],
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.selectedExecutorKey, 'douyin_mashida_skin')
|
||||
assert.equal(result.selectedExecutorKey, 'affiliate_dash')
|
||||
assert.equal(result.reason, '命中商品路由规则')
|
||||
assert.equal(result.matchedRule?.productName, '幸运币2个')
|
||||
})
|
||||
@@ -238,7 +234,7 @@ test('resolveFulfillmentRoute 内部 SKU 规则支持归一化与 contains', ()
|
||||
{
|
||||
internalSku: '幸运币',
|
||||
matchType: 'contains',
|
||||
executorKey: 'douyin_mashida_skin',
|
||||
executorKey: 'affiliate_dash',
|
||||
priority: 500,
|
||||
},
|
||||
],
|
||||
@@ -247,15 +243,15 @@ test('resolveFulfillmentRoute 内部 SKU 规则支持归一化与 contains', ()
|
||||
const exactFullWidth = resolveFulfillmentRoute({
|
||||
productName: '',
|
||||
internalSku: ' 幸运币2个 ',
|
||||
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
|
||||
candidates: [{ executorKey: 'affiliate_dash', available: true }],
|
||||
config,
|
||||
})
|
||||
assert.equal(exactFullWidth.selectedExecutorKey, 'douyin_mashida_skin')
|
||||
assert.equal(exactFullWidth.selectedExecutorKey, 'affiliate_dash')
|
||||
|
||||
const miss = resolveFulfillmentRoute({
|
||||
productName: '',
|
||||
internalSku: '荣耀勋章2个',
|
||||
candidates: [{ executorKey: 'douyin_mashida_skin', available: true }],
|
||||
candidates: [],
|
||||
config,
|
||||
})
|
||||
assert.equal(miss.selectedExecutorKey, 'manual_dispatch')
|
||||
@@ -269,7 +265,7 @@ test('resolveFulfillmentRoute 未命中内部 SKU 规则时商品名规则仍然
|
||||
config: normalizeFulfillmentRoutingConfig({
|
||||
rules: [
|
||||
{ productName: '荣耀套装', executorKey: 'affiliate_dash', priority: 200 },
|
||||
{ internalSku: '幸运币2个', executorKey: 'douyin_mashida_skin', priority: 900 },
|
||||
{ internalSku: '幸运币2个', executorKey: 'affiliate_dash', priority: 900 },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -75,7 +75,6 @@ const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join(
|
||||
|
||||
const ROUTABLE_EXECUTOR_KEYS: string[] = [
|
||||
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||
FULFILLMENT_EXECUTOR_KEYS.DOUYIN_MASHIDA_SKIN,
|
||||
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
import {
|
||||
buildDouyinMashidaClaimMessage,
|
||||
renderDouyinMashidaMessageTemplate,
|
||||
resolveDouyinMashidaSkinClaimUrl,
|
||||
resolveDouyinMashidaClaimUrl,
|
||||
} from './claim-link.js'
|
||||
import {
|
||||
createDefaultDouyinMashidaSourceConfig,
|
||||
@@ -43,18 +43,18 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板
|
||||
assert.equal(config.claimMessageEnabled, false)
|
||||
})
|
||||
|
||||
test('皮肤领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退 hash 版领取页', () => {
|
||||
test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => {
|
||||
assert.equal(
|
||||
resolveDouyinMashidaSkinClaimUrl(
|
||||
resolveDouyinMashidaClaimUrl(
|
||||
'AbC123',
|
||||
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
|
||||
),
|
||||
'https://x.ngrok-free.app/claim/AbC123',
|
||||
)
|
||||
|
||||
assert.equal(resolveDouyinMashidaSkinClaimUrl('AbC123', buildConfig()), '')
|
||||
assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '')
|
||||
assert.equal(
|
||||
resolveDouyinMashidaSkinClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
|
||||
resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildSkinClaimUrl } from '../../claim/claim-service.js'
|
||||
import { buildConfiguredClaimUrl } from '../../claim/claim-service.js'
|
||||
import {
|
||||
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
|
||||
getDouyinMashidaSourceConfig,
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
} from './source-config-service.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达「皮肤自动发货」的领取链接与发货消息拼装(纯函数,便于单测)。
|
||||
* 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。
|
||||
*
|
||||
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
|
||||
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
|
||||
* (那是 hash 形式的打手领取页,语义完全不同)。
|
||||
* (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。
|
||||
*/
|
||||
|
||||
export type DouyinMashidaClaimMessageVars = {
|
||||
@@ -20,12 +20,12 @@ export type DouyinMashidaClaimMessageVars = {
|
||||
productTitle?: string | undefined
|
||||
}
|
||||
|
||||
/** 皮肤领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
|
||||
export function resolveDouyinMashidaSkinClaimUrl(
|
||||
/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
|
||||
export function resolveDouyinMashidaClaimUrl(
|
||||
token: unknown,
|
||||
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
|
||||
): string {
|
||||
return buildSkinClaimUrl(config.claimLinkBase, token)
|
||||
return buildConfiguredClaimUrl(config.claimLinkBase, token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +48,7 @@ export function renderDouyinMashidaMessageTemplate(
|
||||
})
|
||||
}
|
||||
|
||||
/** 皮肤订单付款后发给买家的正文;`claimUrl` 为空返回空串(不发消息)。 */
|
||||
/** 付款成功后发给买家的领取正文;`claimUrl` 为空返回空串(不发消息)。 */
|
||||
export function buildDouyinMashidaClaimMessage(input: {
|
||||
config: DouyinMashidaSourceConfig
|
||||
claimUrl: unknown
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
buildDouyinMashidaSourceEvent,
|
||||
resolveDouyinMashidaParentOrderId,
|
||||
} from './order-mapper.js'
|
||||
import { resolveOrderItemSkinClaimLink } from '../../fulfillment/executors/douyin-mashida-skin-executor.js'
|
||||
import { resolveOrderItemClaimLink } from '../../claim/claim-service.js'
|
||||
import { buildDouyinMashidaClaimMessage } from './claim-link.js'
|
||||
import {
|
||||
getDouyinMashidaSourceConfig,
|
||||
@@ -192,12 +192,12 @@ async function handlePaidMessage(input: {
|
||||
await ensureCertificateForOrderItem({ order, orderItem, config: input.config })
|
||||
certificateCount += 1
|
||||
|
||||
// 皮肤自动发货:发券成功后按模板把领取链接发给买家。
|
||||
// 发券成功后按模板把领取链接发给买家。
|
||||
// best-effort:通知失败只记 warn,绝不影响建单/发券主流程(平台会重推时也不会重复发券)。
|
||||
await notifyBuyerSkinClaimLink({ order, orderItem, config: input.config }).catch((error) => {
|
||||
await notifyBuyerClaimLink({ order, orderItem, config: input.config }).catch((error) => {
|
||||
logIntegration(
|
||||
'[douyin-mashida/notify]',
|
||||
'皮肤领取链接通知买家失败(建单/发券不受影响)',
|
||||
'领取链接通知买家失败(建单/发券不受影响)',
|
||||
{
|
||||
orderId: order.id,
|
||||
orderItemId: orderItem.id,
|
||||
@@ -229,12 +229,16 @@ async function handlePaidMessage(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 皮肤自动发货:付款建单 + 发券成功后,把领取链接按模板通过 `order.message` 发给买家。
|
||||
* 付款建单 + 发券成功后,把买家领取链接按模板通过 `order.message` 发给买家。
|
||||
*
|
||||
* 只在「该订单项确实有皮肤履约任务 + claimLinkBase 已配置 + 开关打开」时才发;
|
||||
* 非皮肤订单静默跳过(不打扰现有兑换流程)。
|
||||
* 这是买家**唯一**会收到的那条领取链接(核销后不再补发第二条,见 redeem/certificate-service.ts):
|
||||
* 买家点链接 → 输 UID → 出二维码 → 扫码绑定 → 系统比对「绑定 UID == 填的 UID」→ 发货完成
|
||||
* (完全复用现成的 affiliate_dash 通道)。
|
||||
*
|
||||
* 只在「该订单项确实有履约任务 claim token + claimLinkBase 已配置 + 开关打开」时才发;
|
||||
* 拿不到链接的订单(例如未命中映射、走人工的订单)静默跳过,不打扰现有兑换流程。
|
||||
*/
|
||||
async function notifyBuyerSkinClaimLink(input: {
|
||||
async function notifyBuyerClaimLink(input: {
|
||||
order: OrderRow
|
||||
orderItem: OrderItemRow
|
||||
config: DouyinMashidaSourceConfig
|
||||
@@ -246,19 +250,19 @@ async function notifyBuyerSkinClaimLink(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const skinLink = await resolveOrderItemSkinClaimLink({
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
config: input.config,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
if (!skinLink.claimUrl) {
|
||||
if (!claimLink.claimUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
|
||||
const content = buildDouyinMashidaClaimMessage({
|
||||
config: input.config,
|
||||
claimUrl: skinLink.claimUrl,
|
||||
claimUrl: claimLink.claimUrl,
|
||||
sku: input.orderItem.sku_code,
|
||||
orderNo,
|
||||
productTitle: input.orderItem.sku_name,
|
||||
@@ -269,11 +273,11 @@ async function notifyBuyerSkinClaimLink(input: {
|
||||
|
||||
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
|
||||
|
||||
logIntegration('[douyin-mashida/notify]', '已把皮肤领取链接发给买家(order.message)', {
|
||||
logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', {
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
orderNo,
|
||||
taskId: skinLink.pickedTaskId,
|
||||
taskId: claimLink.taskId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ import { sanitizeWellFormedUnicode } from '../../../utils/json.js'
|
||||
export const DOUYIN_MASHIDA_PROVIDER = 'douyin_mashida'
|
||||
export const DOUYIN_MASHIDA_PLATFORM = 'doudian'
|
||||
|
||||
/**
|
||||
* 是否抖音码速达订单(provider 口径)。
|
||||
*
|
||||
* 履约/领取链接的判定一律用它,**不要**按 executor_key 判断:
|
||||
* 抖音皮肤/礼包单已改走现成的 `affiliate_dash` 通道,按执行器判断会失效。
|
||||
*/
|
||||
export function isDouyinMashidaProvider(provider: unknown): boolean {
|
||||
return String(provider || '').trim() === DOUYIN_MASHIDA_PROVIDER
|
||||
}
|
||||
|
||||
/** 内部订单状态只有 4 个值(order-service.ts:432)——抖店状态按「多对一」降级。 */
|
||||
export const DOUYIN_MASHIDA_ORDER_STATUS_MAP: Record<
|
||||
string,
|
||||
|
||||
@@ -18,9 +18,13 @@ import {
|
||||
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
|
||||
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
|
||||
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
|
||||
* 皮肤自动发货订单会优先用「本站领取链接」(`claimLinkBase` + token),此时 `certLink` 退化为兜底。
|
||||
* - `claimLinkBase` 是皮肤领取页的**路径式**基址(如 `https://x.ngrok-free.app/claim`);
|
||||
* 它**不是**打手领取页(`runtimeConfig.orders.claimBaseUrl` 那个 hash 版),两者不能互相回退。
|
||||
* 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token),
|
||||
* 此时 `certLink` 退化为兜底。
|
||||
* - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程
|
||||
* (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。
|
||||
* 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与
|
||||
* hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl`
|
||||
* (两条口径不能互相回退)。
|
||||
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
|
||||
*/
|
||||
|
||||
@@ -32,7 +36,7 @@ export const DOUYIN_MASHIDA_DEFAULT_BASE_URL = 'https://dy-openplatform-api.wanm
|
||||
/** 兑换成功后 order.message 的默认正文(可在后台覆盖)。 */
|
||||
export const DEFAULT_AFTER_VERIFY_MESSAGE = '您的订单已兑换成功'
|
||||
|
||||
/** 皮肤自动发货:付款后用该模板把领取链接发给买家(可在后台覆盖)。 */
|
||||
/** 付款后用该模板把买家领取链接发给他(可在后台覆盖)。 */
|
||||
export const DEFAULT_CLAIM_MESSAGE_TEMPLATE = '您的订单已发货,请点击链接领取:{link}'
|
||||
|
||||
export const DOUYIN_MASHIDA_DEFAULT_HANDLED_TOPICS = [
|
||||
@@ -46,13 +50,13 @@ export type DouyinMashidaSourceConfig = {
|
||||
baseUrl: string
|
||||
uid: string
|
||||
secret: string
|
||||
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem;皮肤订单无领取链接时兜底用它 */
|
||||
/** 固定兑换链接(cert_link),如 https://ks.khhao.com/redeem;无买家领取链接时兜底用它 */
|
||||
certLink: string
|
||||
/** 皮肤领取页基址(路径式,如 https://x.ngrok-free.app/claim),留空则不生成皮肤领取链接 */
|
||||
/** 买家领取页基址(affiliate-dash 领取流程,如 https://<域名>/claim),留空则不生成/不发领取链接 */
|
||||
claimLinkBase: string
|
||||
/** 皮肤自动发货:付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */
|
||||
/** 付款成功发给买家的 message 模板,支持 {link}/{sku}/{orderNo}/{productTitle} */
|
||||
messageTemplate: string
|
||||
/** 皮肤自动发货「付款后发消息」开关,默认开 */
|
||||
/** 「付款后发领取链接」开关,默认开 */
|
||||
claimMessageEnabled: boolean
|
||||
/** order.message 的 message_type:1-订单状态 2-订单发货 4-图片 5-视频 */
|
||||
messageType: number
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../repositories/redeem-certificate-repo.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
|
||||
import { resolveOrderItemSkinClaimLink } from '../fulfillment/executors/douyin-mashida-skin-executor.js'
|
||||
import { resolveOrderItemClaimLink } from '../claim/claim-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -40,6 +40,11 @@ import {
|
||||
* 闭环:建单后**我们生成券码**(DYC + Crockford Base32)→ `coupons.syncV2` 交给平台展示给买家
|
||||
* → 买家在 `https://<域名>/redeem` 输入订单号兑换 → `coupons.verifyV3` 上报核销。
|
||||
*
|
||||
* 皮肤/礼包类订单(命中 affiliate-dash 映射)**不走**上面的输入订单号兑换:
|
||||
* 付款时按 `claimLinkBase` 把买家领取链接发给买家,买家在领取页输 UID → 扫码绑定 →
|
||||
* 「绑定 UID == 填的 UID」即发货完成(见 claim/kuaishou-cloud-claim-service.ts),
|
||||
* 完成时由该流程 best-effort 调 `verifyCertificateForOrderItem` 把平台券核销掉。
|
||||
*
|
||||
* 幂等:
|
||||
* - 发券:`(provider, order_id)` 唯一,重复回调只会复用同一张券;
|
||||
* - 同步:`sync_status='success'` 直接跳过,失败可重试;
|
||||
@@ -70,7 +75,7 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
})
|
||||
}
|
||||
|
||||
// 皮肤自动发货:cert_link 用「本站领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。
|
||||
// 该订单项已有领取 token 时 cert_link 用「买家领取链接」;其它情况沿用固定兑换链接 certLink(原行为不变)。
|
||||
const certLink = await resolveDouyinMashidaCertLink({
|
||||
order: input.order,
|
||||
orderItem: input.orderItem,
|
||||
@@ -121,12 +126,13 @@ export async function ensureCertificateForOrderItem(input: {
|
||||
* 解析本次同步要交给平台的 `cert_link`。
|
||||
*
|
||||
* 优先级(**只对新单生效**:已同步成功的券不会再改动):
|
||||
* 1. 皮肤自动发货任务已生成 claim token,且配置了 `claimLinkBase`
|
||||
* → `claimLinkBase/token`(买家领取页,路径式);
|
||||
* 1. 该订单项已有履约任务 claim token,且配置了 `claimLinkBase`
|
||||
* → `claimLinkBase/token`(买家领链接);
|
||||
* 2. 其它情况 → `config.certLink`(固定兑换链接,历史行为完全不变)。
|
||||
*
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,这里取最小 task id 的那个,
|
||||
* 保证「同一张券每次解析出同一条链接」,并打 warn 留痕提示运营。
|
||||
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
|
||||
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
|
||||
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
|
||||
*/
|
||||
export async function resolveDouyinMashidaCertLink(input: {
|
||||
order: OrderRow
|
||||
@@ -138,21 +144,23 @@ export async function resolveDouyinMashidaCertLink(input: {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const skinLink = await resolveOrderItemSkinClaimLink({
|
||||
const claimLink = await resolveOrderItemClaimLink({
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
config: input.config,
|
||||
claimLinkBase: input.config.claimLinkBase,
|
||||
})
|
||||
|
||||
if (!skinLink.claimUrl) {
|
||||
if (skinLink.taskCount > 0) {
|
||||
if (!claimLink.claimUrl) {
|
||||
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
|
||||
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
|
||||
if (claimLink.taskCount > 0) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'皮肤订单尚无可用 claim token,cert_link 回退固定兑换链接',
|
||||
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
skinTaskCount: skinLink.taskCount,
|
||||
taskCount: claimLink.taskCount,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
@@ -160,21 +168,7 @@ export async function resolveDouyinMashidaCertLink(input: {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (skinLink.taskCount > 1) {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'同一订单项存在多个皮肤履约单元,cert_link 取首个 token',
|
||||
{
|
||||
orderId: input.order.id,
|
||||
orderItemId: input.orderItem.id,
|
||||
skinTaskCount: skinLink.taskCount,
|
||||
pickedTaskId: skinLink.pickedTaskId,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return skinLink.claimUrl
|
||||
return claimLink.claimUrl
|
||||
}
|
||||
|
||||
/** 调 `coupons.syncV2` 把券码 + 兑换/领取链接交给平台;失败会标记并抛出(让平台重推/人工重试)。 */
|
||||
@@ -312,20 +306,36 @@ export async function verifyCertificate(input: {
|
||||
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' },
|
||||
)
|
||||
})
|
||||
// 抖音码速达订单**不再**在核销后补发第二条消息:
|
||||
// 买家在付款建单时已经收到「唯一那条领取链接」(见 douyin-mashida/notify-service.ts),
|
||||
// 之后再发一条带链接的消息会让买家拿到第二条链接、分不清该点哪个。
|
||||
// 其它平台(本表未来接入的 provider)行为保持不变:继续按 afterVerifyMessage/afterVerifyLink 通知。
|
||||
if (shouldNotifyBuyerAfterVerify(verified || found)) {
|
||||
await notifyBuyerAfterVerify({ certificate: verified || found, config }).catch((error) => {
|
||||
logIntegration(
|
||||
'[douyin-mashida/certificate]',
|
||||
'兑换成功通知买家失败(兑换结果不受影响)',
|
||||
{ certNo, orderId: found.order_id, error },
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return verified || found
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销后是否还要给买家发「去使用链接」消息。
|
||||
*
|
||||
* 抖音码速达:**不发**(买家已拿到领取链接;抖音皮肤/礼包单的领取链接本身就是完成入口)。
|
||||
* 其它平台:保持原行为(发)。
|
||||
*/
|
||||
export function shouldNotifyBuyerAfterVerify(
|
||||
certificate: Pick<RedeemCertificateRow, 'provider'> | null | undefined,
|
||||
): boolean {
|
||||
return String(certificate?.provider || '').trim() !== DOUYIN_MASHIDA_PROVIDER
|
||||
}
|
||||
|
||||
/** 拼 order.message 的正文:默认文案 +(可选的)去使用链接。 */
|
||||
export function buildAfterVerifyMessage(config: DouyinMashidaSourceConfig): string {
|
||||
const message = String(config.afterVerifyMessage || '').trim() || DEFAULT_AFTER_VERIFY_MESSAGE
|
||||
|
||||
@@ -12,8 +12,6 @@ export type AdminTaskDetailView = {
|
||||
order: JsonObject | null
|
||||
orderItem: JsonObject | null
|
||||
claimToken: JsonObject | null
|
||||
/** 皮肤自动发货:买家在领取页提交的游戏账号(敏感数据,按后台角色门禁下发) */
|
||||
claimSubmission: JsonObject | null
|
||||
artifacts: JsonObject
|
||||
screenshotUrl: string
|
||||
review: JsonObject
|
||||
|
||||
@@ -118,8 +118,6 @@ export type TaskUpdatePatch = {
|
||||
attempt_count?: number
|
||||
last_error?: string
|
||||
context_json?: string | Record<string, unknown>
|
||||
/** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */
|
||||
claim_submission_json?: string | Record<string, unknown>
|
||||
claimed_at?: string | null
|
||||
role_confirmed_at?: string | null
|
||||
redeemed_at?: string | null
|
||||
|
||||
@@ -66,8 +66,6 @@ export type TaskRow = {
|
||||
primary_claim_expires_at?: string | null
|
||||
artifacts_json: string | Record<string, unknown>
|
||||
context_json: string | Record<string, unknown>
|
||||
/** 买家领取页提交内容(皮肤自动发货);隐私字段,禁止进入打手工单素材 */
|
||||
claim_submission_json?: string | Record<string, unknown>
|
||||
screenshot_path: string
|
||||
last_error: string
|
||||
retry_count: number
|
||||
|
||||
@@ -27,18 +27,6 @@ export function buildSuccessPayload(data: unknown, msg: string = 'ok') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `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() : ''
|
||||
@@ -66,7 +54,6 @@ export function sendRouteError(
|
||||
error: unknown,
|
||||
fallbackMessage = '服务内部错误',
|
||||
scope = '[route]',
|
||||
options: { messageAlias?: boolean } = {},
|
||||
): void {
|
||||
const classification = classifyRouteError(error)
|
||||
const logLevel = resolveRouteErrorLogLevel(classification)
|
||||
@@ -77,12 +64,7 @@ export function sendRouteError(
|
||||
// 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)
|
||||
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
|
||||
}
|
||||
|
||||
export function resolveRouteErrorLogLevel(classification: {
|
||||
|
||||
Reference in New Issue
Block a user