fix(claim): 统一所有买家领取链接为 H5 订单号格式

This commit is contained in:
yml2213
2026-11-02 22:11:22 +08:00
parent 3989b0a649
commit 4dc2f5ca90
38 changed files with 298 additions and 362 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ BACKEND_PORT=3000
HTTP_TRUST_PROXY=1
# Claim Links
CLAIM_BASE_URL=http://localhost/#/claim
CLAIM_BASE_URL=http://localhost/h5
# Database
POSTGRES_DB=order_site
+1 -1
View File
@@ -23,7 +23,7 @@ HTTP_TRUST_PROXY=1
# BASE_REGISTRY=docker.m.daocloud.io
# Claim Links
CLAIM_BASE_URL=https://order.khhao.com/#/claim
CLAIM_BASE_URL=https://order.khhao.com/h5
CLAIM_TOKEN_TTL_HOURS=0
# Database
+1 -1
View File
@@ -194,7 +194,7 @@ cp .env.server.example .env
| --- | --- |
| `APP_DOMAIN` / `CADDY_SITE_ADDR` | 后台与领取页域名、Caddy 主站监听地址 |
| `WORKER_SITE_ADDR` | 打手端独立域名,如 `https://worker.khhao.com` |
| `CLAIM_BASE_URL` | 领取页完整前缀,如 `https://域名/#/claim` |
| `CLAIM_BASE_URL` | 买家领取页基址,如 `https://域名/h5`;对外链接统一为 `/h5/<订单号>` |
| `CLAIM_TOKEN_TTL_HOURS` | 领取链接有效期(小时);`0` 表示长期有效,正整数表示限时 |
| `DATABASE_URL` | PostgreSQL 连接串(容器内主机名用 `postgres`) |
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
+1 -1
View File
@@ -62,7 +62,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
},
orders: {
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
claimBaseUrl: 'http://127.0.0.1:5173/h5',
// 0 表示长期有效;后台手动关闭任务后领取链接仍会立即失效。
tokenTtlHours: 0,
},
@@ -41,7 +41,7 @@ test('applyEnvOverrides derives claim base url from app base url when explicit c
APP_BASE_URL: 'https://example.test///',
})
assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim')
assert.equal(config.orders.claimBaseUrl, 'https://example.test/h5')
})
test('CLAIM_TOKEN_TTL_HOURS=0 表示领取链接长期有效', () => {
+1 -1
View File
@@ -158,7 +158,7 @@ function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void
const appBaseUrl = parseString(env.APP_BASE_URL)
if (appBaseUrl !== null) {
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim`
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/h5`
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* 历史短链只读跳转入口(/s/:code)。
* 新 claim 交付请使用本站 /#/claim/{token},勿再为 feifei 生成 shortLink。
* 新 claim 交付请使用本站 /h5/{订单号},勿再为 feifei 生成 shortLink。
*/
import { Router } from 'express'
@@ -1,4 +1,4 @@
import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js'
import { buildOrderClaimUrl } from '../claim/claim-service.js'
import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js'
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
import {
@@ -385,9 +385,7 @@ export async function getAdminTaskDetail(
const taskContext = parseTaskContext(task)
const taskState = parseTaskState(task)
const viewerContext = createAdminViewerContext(session)
// 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key):
// 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程);
// 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。
// 后台回显的领取链接统一为 `/h5/<平台订单号>`;抖音码速达仍可使用自己的领取站基址。
const claimUrl = resolveAdminTaskClaimUrl(claimToken, order)
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
const cloudSourceLabelMap = buildCloudSourceLabelMap()
@@ -514,32 +512,24 @@ export async function getAdminTaskDetail(
}
/**
* 后台展示的领取链接。
* 后台展示的买家领取链接,所有来源统一为 `/h5/<平台订单号>`。
*
* - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页,
* 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。
* - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。
*
* ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道,
* 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。
* 抖音码速达可继续使用自己配置的领取站基址,其它来源使用全局 claimBaseUrl;
* 订单号缺失时不生成链接。链接不再依赖 claim token。
*/
function resolveAdminTaskClaimUrl(
claimToken: { token: string } | null | undefined,
order: Pick<OrderRow, 'provider'> | null | undefined,
order: Pick<OrderRow, 'provider' | 'platform_order_id'> | null | undefined,
): string {
const token = String(claimToken?.token || '').trim()
if (!token) {
if (!String(claimToken?.token || '').trim() || !order) {
return ''
}
if (isDouyinMashidaProvider(order?.provider)) {
const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token)
if (claimUrl) {
return claimUrl
}
}
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
? getDouyinMashidaSourceConfig().claimLinkBase
: ''
return buildClaimUrl(token)
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
}
function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null {
@@ -33,6 +33,7 @@ import {
cancelCertificateVerify,
ensureCertificateForOrderItem,
formatGrantTime,
resolveDouyinMashidaCertLink,
verifyCertificate,
} from '../redeem/certificate-service.js'
import type {
@@ -154,7 +155,18 @@ export async function syncAdminDouyinMashidaCertificate(
})
}
const certLink = String(certificate.cert_link || config.certLink || '').trim()
const context = await resolveCertificateOrderContext({
...(certificate.local_order_id !== null ? { localOrderId: certificate.local_order_id } : {}),
...(certificate.order_item_id !== null ? { orderItemId: certificate.order_item_id } : {}),
})
const regeneratedCertLink = await resolveDouyinMashidaCertLink({
order: context.order,
orderItem: context.orderItem,
config,
})
const certLink = String(
regeneratedCertLink || certificate.cert_link || config.certLink || '',
).trim()
if (!certLink) {
throw createHttpError('未配置兑换链接(certLink),无法同步券码', {
statusCode: 400,
@@ -1,4 +1,4 @@
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../utils/masking.js'
import type { TaskRow } from '../../types/repository/rows.js'
@@ -52,11 +52,11 @@ export async function ensureTaskClaimLink(task: TaskRow): Promise<TaskClaimLink>
return {
token,
expiredAt,
claimUrl: buildClaimUrl(token),
claimUrl: buildOrderClaimUrl(task.platform_order_id),
}
}
const claimToken = await createTaskClaimToken(task.id)
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
return {
token: claimToken.token,
expiredAt: claimToken.expired_at,
@@ -5,11 +5,7 @@ import {
import { updateTask } from '../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import {
createTaskClaimToken,
buildClaimUrl,
buildConfiguredClaimUrl,
} from '../../claim/claim-service.js'
import { buildOrderClaimUrl, createTaskClaimToken } 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'
@@ -240,33 +236,25 @@ export async function retryAdminTask(
return response
}
/**
* 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key):
* - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程);
* 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空;
* - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。
*/
/** 后台回显的买家领取链接统一为 `/h5/<平台订单号>`,不再生成 token 链接。 */
async function resolveAdminTaskClaimUrl(
task: Pick<TaskRow, 'order_id'>,
token: string,
): Promise<string> {
const normalizedToken = String(token || '').trim()
if (!normalizedToken) {
if (!String(token || '').trim()) {
return ''
}
const order = await getOrderById(task.order_id)
if (isDouyinMashidaProvider(order?.provider)) {
const claimUrl = buildConfiguredClaimUrl(
getDouyinMashidaSourceConfig().claimLinkBase,
normalizedToken,
)
if (claimUrl) {
return claimUrl
}
if (!order) {
return ''
}
return buildClaimUrl(normalizedToken)
const douyinClaimBase = isDouyinMashidaProvider(order.provider)
? getDouyinMashidaSourceConfig().claimLinkBase
: ''
return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined)
}
export async function regenerateAdminTaskClaimLink(
@@ -306,7 +294,7 @@ export async function regenerateAdminTaskClaimLink(
})
}
const claimToken = await createTaskClaimToken(task.id)
const claimToken = await createTaskClaimToken(task.id, task.platform_order_id)
const nextStatus =
task.task_status === TASK_STATUS.MANUAL_REVIEW || task.task_status === TASK_STATUS.RETRY_PENDING
? TASK_STATUS.LINK_GENERATED
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildConfiguredClaimUrl,
buildOrderClaimUrl,
pickOrderItemClaimTask,
resolveClaimTokenExpiration,
} from './claim-service.js'
@@ -18,26 +18,40 @@ test('领取链接保留正整数小时的限时配置能力', () => {
)
})
test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => {
test('所有来源统一生成 /h5/<订单号> 领取链接', () => {
const orderNo = '6919156612984680391'
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'),
'https://x.ngrok-free.app/#/claim/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/#/claim'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'),
'https://x.ngrok-free.app/claim/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildConfiguredClaimUrl('https://x.ngrok-free.app', 'AbC123'),
'https://x.ngrok-free.app/AbC123',
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/custom'),
`https://x.ngrok-free.app/custom/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5///'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5/old-token'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
assert.equal(
buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/old-token'),
`https://x.ngrok-free.app/h5/${orderNo}`,
)
})
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('订单号或领取基址缺失/无效时返回空串', () => {
assert.equal(buildOrderClaimUrl('', 'https://x/claim'), '')
assert.equal(buildOrderClaimUrl(null, 'https://x/claim'), '')
assert.equal(buildOrderClaimUrl('6919156612984680391', ''), '')
assert.equal(buildOrderClaimUrl('6919156612984680391', 'not-a-url'), '')
})
test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => {
@@ -1,6 +1,6 @@
import { runtimeConfig } from '../../config/runtime.js'
import { createClaimToken } from '../../repositories/claim-token-repo.js'
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { getTaskById, 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'
@@ -11,7 +11,10 @@ type TaskClaimToken = NonNullable<Awaited<ReturnType<typeof createClaimToken>>>
claimUrl: string
}
export async function createTaskClaimToken(taskId: number | string): Promise<TaskClaimToken> {
export async function createTaskClaimToken(
taskId: number | string,
orderNo?: unknown,
): Promise<TaskClaimToken> {
const createdAt = nowIso()
const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours)
const token = await createClaimToken({
@@ -33,9 +36,12 @@ export async function createTaskClaimToken(taskId: number | string): Promise<Tas
})
}
const resolvedOrderNo =
orderNo === undefined ? (await getTaskById(Number(taskId)))?.platform_order_id : orderNo
return {
...token,
claimUrl: buildClaimUrl(token.token),
claimUrl: buildOrderClaimUrl(resolvedOrderNo),
}
}
@@ -49,36 +55,63 @@ export function resolveClaimTokenExpiration(
}
/**
* 全局默认领取链接:`runtimeConfig.orders.claimBaseUrl` + `/` + token。
* 所有来源统一的买家领取链接:`<领取站>/h5/<平台订单号>`。
*
* ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接
* 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。
* `claimBaseUrl` 兼容历史配置形态(如 `https://x/#/claim`、`https://x/claim`),
* 这里会归一化到同一站点的 `/h5` 路径;订单号为空或基址无效时返回空串。
*/
export function buildClaimUrl(token: string): string {
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
}
/**
* 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。
*
* 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页),
* 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。
* 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。
*/
export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string {
const base = String(baseUrl || '')
.trim()
.replace(/\/+$/, '')
const normalizedToken = String(token || '').trim()
if (!base || !normalizedToken) {
export function buildOrderClaimUrl(
orderNo: unknown,
claimBaseUrl: unknown = runtimeConfig.orders.claimBaseUrl,
): string {
const normalizedOrderNo = String(orderNo ?? '').trim()
if (!normalizedOrderNo) {
return ''
}
return `${base}/${normalizedToken}`
const baseUrl = resolveH5ClaimBaseUrl(claimBaseUrl)
if (!baseUrl) {
return ''
}
return `${baseUrl}/${encodeURIComponent(normalizedOrderNo)}`
}
/** 把任意历史领取页基址归一化成站点下的 `/h5` 基址。 */
export function resolveH5ClaimBaseUrl(claimBaseUrl: unknown): string {
const rawValue = String(claimBaseUrl ?? '').trim()
if (!rawValue) {
return ''
}
let url: URL
try {
url = new URL(rawValue)
} catch {
return ''
}
const pathname = url.pathname.replace(/\/+$/, '')
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
if (h5Match?.index !== undefined) {
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
} else if (claimMatch?.index !== undefined) {
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
} else if (pathname && pathname !== '/') {
url.pathname = `${pathname}/h5`
} else {
url.pathname = '/h5'
}
url.search = ''
url.hash = ''
return url.toString().replace(/\/+$/, '')
}
export type OrderItemClaimLink = {
/** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */
/** 统一 H5 领取链接;无可用履约单元或订单号时为空串 */
claimUrl: string
/** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */
taskId?: number
@@ -97,7 +130,7 @@ export type OrderItemClaimTaskPick = {
token: string
/** 该订单项下的履约任务总数,不论有没有 token */
taskCount: number
/** 其中带 claim token 的单元数(>1 时会有多个领取链接) */
/** 其中带 claim token 的单元数;对外仍共用一个订单号链接 */
tokenTaskCount: number
}
@@ -105,7 +138,7 @@ export type OrderItemClaimTaskPick = {
* 纯函数:从订单的履约任务里挑出该订单项的领取单元。
*
* 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token;
* 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。
* 这里固定取 **task id 最小** 的那个,用于判断该订单项是否已经准备好自动履约。
*/
export function pickOrderItemClaimTask(
tasks: Array<Partial<TaskRow>> | null | undefined,
@@ -142,17 +175,17 @@ export function pickOrderItemClaimTask(
}
/**
* 通用领取链接解析(不再依赖任何具体执行器):
* 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。
* 通用领取单元解析(不再依赖任何具体执行器):
* 先按订单项找到带 claim token 的履约任务,再生成统一的 `/h5/<订单号>` 链接。
*
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token,
* 因此这里只按 `order_item_id` 找任务、**不看 executor_key**:
* 换执行器不会再让领取链接解析失效。
* 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道。这里只按 `order_item_id` 找任务、
* **不看 executor_key**,换执行器不会影响链接和自动发货判断。
*/
export async function resolveOrderItemClaimLink(input: {
orderId: number | string
orderNo: unknown
orderItemId: number | string
claimLinkBase: unknown
claimLinkBase?: unknown
}): Promise<OrderItemClaimLink> {
const orderId = Number(input.orderId)
if (!Number.isFinite(orderId)) {
@@ -180,7 +213,7 @@ export async function resolveOrderItemClaimLink(input: {
}
return {
claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token),
claimUrl: buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined),
taskId: picked.taskId,
taskCount: picked.taskCount,
}
@@ -11,7 +11,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js'
import { buildClaimUrl } from './claim-service.js'
import { buildOrderClaimUrl } 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'
@@ -301,7 +301,7 @@ function buildAffiliateDashClaimDetailPayload({
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType: 'affiliate_dash' as const,
channelOffline: false,
channelOfflineMessage: '',
@@ -382,7 +382,7 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }:
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType:
executorKey === 'kuaishou_feifei'
? ('kuaishou_feifei' as const)
@@ -436,7 +436,7 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }:
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(order.platform_order_id),
flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch',
channelOffline: false,
channelOfflineMessage: '',
@@ -12,7 +12,7 @@ import {
getFulfillmentProfileByKey,
upsertFulfillmentProfile,
} from '../../repositories/fulfillment-profile-repo.js'
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js'
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js'
import {
@@ -258,7 +258,7 @@ export async function createAffiliateDashMockClaim(
})
}
const claimToken = await createTaskClaimToken(task.id)
const claimToken = await createTaskClaimToken(task.id, orderNo)
await updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
@@ -274,7 +274,7 @@ export async function createAffiliateDashMockClaim(
taskId: task.id,
taskNo: task.task_no,
claimToken: claimToken.token,
claimUrl: buildClaimUrl(claimToken.token),
claimUrl: buildOrderClaimUrl(orderNo),
frontendBaseUrl: input.frontendBaseUrl,
expectedUid: fallbackUid,
step,
@@ -633,7 +633,7 @@ function buildCreateResult(input: {
taskNo: input.taskNo,
claimToken: input.claimToken,
claimUrl: input.claimUrl,
frontendClaimUrl: buildFrontendClaimUrl(input.claimToken, input.frontendBaseUrl),
frontendClaimUrl: buildOrderClaimUrl(input.orderNo, input.frontendBaseUrl || undefined),
expectedUid: input.expectedUid,
step: input.step,
productName: input.productName,
@@ -642,14 +642,6 @@ function buildCreateResult(input: {
}
}
function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) {
const base = String(frontendBaseUrl || '').trim()
if (base) {
return `${base.replace(/\/+$/, '')}/#/claim/${token}`
}
return `http://127.0.0.1/#/claim/${token}`
}
function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) {
if (step === 'uid') {
return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码']
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildClaimUrl } from '../claim/claim-service.js'
import { buildOrderClaimUrl } from '../claim/claim-service.js'
import { resolveTaskDeliveryLink } from './delivery-link-service.js'
import type { TaskRow } from '../../types/repository/rows.js'
@@ -15,7 +15,7 @@ test('resolveTaskDeliveryLink 为 affiliate_dash 任务返回本站领取链接'
)
assert.deepEqual(result, {
claimUrl: buildClaimUrl('affiliate-token'),
claimUrl: buildOrderClaimUrl('2614900602069169'),
expireTime: '2026-07-09T00:00:00.000Z',
})
})
@@ -30,7 +30,7 @@ test('resolveTaskDeliveryLink 兼容 claim_token 回落字段', async () => {
)
assert.deepEqual(result, {
claimUrl: buildClaimUrl('legacy-token'),
claimUrl: buildOrderClaimUrl('2614900602069169'),
expireTime: '2026-07-09T08:00:00.000Z',
})
})
@@ -1,4 +1,4 @@
import { buildClaimUrl } from '../../claim/claim-service.js'
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { prepareAffiliateDashTask } from '../affiliate-dash/index.js'
import {
@@ -22,7 +22,7 @@ async function preparePaidTask(
try {
let workingTask = task
if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) {
const claimToken = await deps.createTaskClaimToken(task.id)
const claimToken = await deps.createTaskClaimToken(task.id, task.platform_order_id)
workingTask =
(await deps.updateTask(task.id, {
claim_token: claimToken.token,
@@ -64,7 +64,7 @@ async function resolveDeliveryLink(task: TaskRow): Promise<FulfillmentDeliveryLi
}
return {
claimUrl: buildClaimUrl(primaryToken),
claimUrl: buildOrderClaimUrl(task.platform_order_id),
expireTime,
}
}
@@ -23,7 +23,10 @@ export type FulfillmentDeliveryLink = {
export type FulfillmentPrepareDeps = {
updateTask: (taskId: number | string, patch: TaskUpdatePatch) => Promise<TaskRow | null>
createTaskClaimToken: (taskId: number | string) => Promise<{
createTaskClaimToken: (
taskId: number | string,
orderNo?: unknown,
) => Promise<{
token: string
expired_at: string | null
[key: string]: unknown
@@ -117,14 +117,14 @@ test('resolveOpen91QueryState returns success only when every task is ready', ()
test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => {
assert.deepEqual(
buildOpen91CardItem({
claimUrl: 'https://example.com/#/claim/abc123xyz',
claimUrl: 'https://example.com/h5/6919156612984680391',
expireTime: '2026-05-05 12:00:00',
}),
{
cardNo: 'https://example.com/#/claim/abc123xyz',
cardNo: 'https://example.com/h5/6919156612984680391',
cardPwd: '',
expireTime: '2026-05-05 12:00:00',
jumpLink: 'https://example.com/#/claim/abc123xyz',
jumpLink: 'https://example.com/h5/6919156612984680391',
},
)
})
@@ -1,12 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
import {
buildDouyinMashidaBuyerClaimUrl,
buildDouyinMashidaClaimMessage,
isDouyinMashidaH5ClaimBase,
renderDouyinMashidaMessageTemplate,
resolveDouyinMashidaClaimUrl,
} from './claim-link.js'
import {
createDefaultDouyinMashidaSourceConfig,
@@ -45,22 +44,6 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板
assert.equal(config.claimMessageEnabled, false)
})
test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => {
assert.equal(
resolveDouyinMashidaClaimUrl(
'AbC123',
buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }),
),
'https://x.ngrok-free.app/claim/AbC123',
)
assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '')
assert.equal(
resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })),
'',
)
})
test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => {
assert.equal(
renderDouyinMashidaMessageTemplate(
@@ -105,16 +88,6 @@ test('发消息正文:无领取链接则不发(返回空串),默认模
)
})
test('领取链接基址形态:/h5 与 /h5/xxx 判为 H5 订单号形态,其它不是', () => {
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5'), true)
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/'), true)
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/anything'), true)
assert.equal(isDouyinMashidaH5ClaimBase(' https://ks.khhao.com/claim '), false)
assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5x'), false)
assert.equal(isDouyinMashidaH5ClaimBase(''), false)
assert.equal(isDouyinMashidaH5ClaimBase(undefined), false)
})
test('买家领取消息链接:没有自动发货领取单元(人工单)一律返回空串 = 不发消息', () => {
// 线上实测的缺陷场景:未命中映射的「套装-牛仔情诗」单回落人工,却收到了「已发货」领取链接
assert.equal(
@@ -126,19 +99,18 @@ test('买家领取消息链接:没有自动发货领取单元(人工单)
'',
)
// token 形态基址同样不发:人工单本来就没有 token,不能拿订单号去凑
// 历史 /claim 基址同样不发:人工单本来就没有自动发货单元
assert.equal(
buildDouyinMashidaBuyerClaimUrl({
claimLinkBase: 'https://ks.khhao.com/claim',
orderNo: '6918490107300556291',
hasAutoClaimUnit: false,
tokenClaimUrl: '',
}),
'',
)
})
test('买家领取消息链接:有自动发货领取单元时按基址形态拼接', () => {
test('买家领取消息链接:有自动发货领取单元时统一生成 /h5/订单号', () => {
// /h5 形态:拼订单号(结尾斜杠、结尾多斜杠都容忍)
assert.equal(
buildDouyinMashidaBuyerClaimUrl({
@@ -157,15 +129,14 @@ test('买家领取消息链接:有自动发货领取单元时按基址形态
'https://ks.khhao.com/h5/6918490107300556291',
)
// token 形态:只用 resolveOrderItemClaimLink 解析出的链接
// 历史 /claim 基址也归一化到 /h5,不再回退 token 链接
assert.equal(
buildDouyinMashidaBuyerClaimUrl({
claimLinkBase: 'https://ks.khhao.com/claim',
orderNo: '6918490107300556291',
hasAutoClaimUnit: true,
tokenClaimUrl: 'https://ks.khhao.com/claim/AbC123',
}),
'https://ks.khhao.com/claim/AbC123',
'https://ks.khhao.com/h5/6918490107300556291',
)
})
@@ -176,7 +147,7 @@ test('买家领取消息链接:基址或订单号缺失时返回空串(不
orderNo: '6918490107300556291',
hasAutoClaimUnit: true,
}),
'',
buildOrderClaimUrl('6918490107300556291'),
)
assert.equal(
buildDouyinMashidaBuyerClaimUrl({
@@ -1,4 +1,4 @@
import { buildConfiguredClaimUrl } from '../../claim/claim-service.js'
import { buildOrderClaimUrl } from '../../claim/claim-service.js'
import {
DEFAULT_CLAIM_MESSAGE_TEMPLATE,
getDouyinMashidaSourceConfig,
@@ -8,9 +8,7 @@ import {
/**
* 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。
*
* 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。
* ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl`
* (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。
* 买家链接统一为 `/h5/<平台订单号>`;配置了更具体的领取站基址时沿用其站点。
*/
export type DouyinMashidaClaimMessageVars = {
@@ -20,25 +18,6 @@ export type DouyinMashidaClaimMessageVars = {
productTitle?: string | undefined
}
/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */
export function resolveDouyinMashidaClaimUrl(
token: unknown,
config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(),
): string {
return buildConfiguredClaimUrl(config.claimLinkBase, token)
}
/**
* 领取链接基址是否是**路径式 H5 订单号**形态(`https://<域名>/h5`、`.../h5/xxx`)。
*
* 这种基址拼的是**订单号**(`/h5/<订单号>`),不是 claim token;两种形态的拼接规则不同,
* 判断只此一处,避免各调用方各写一份。
*/
export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
const base = normalizeClaimLinkBase(claimLinkBase)
return Boolean(base) && (base.endsWith('/h5') || base.includes('/h5/'))
}
/**
* 买家消息里真正要发出去的领取链接(**空串 = 这条消息不许发**)。
*
@@ -52,40 +31,21 @@ export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean {
* 历史缺陷:`/h5` 基址原先走了一条捷径——只拼订单号、跳过履约任务校验,
* 于是人工单也照样收到领取消息(线上实测:未命中映射的「套装-牛仔情诗」单被发了链接)。
*
* 拼接规则按基址形态分流:
* - `/h5` 形态 → `claimLinkBase` + `/` + 订单号;
* - 其它形态 → `resolveOrderItemClaimLink` 解析出的 token 链接。
* 拼接规则:有自动发货领取单元时,所有来源统一发 `/h5/<平台订单号>`。
* 历史配置的 `/#/claim`、`/claim` 等基址也会由公共 helper 归一化到 `/h5`。
*/
export function buildDouyinMashidaBuyerClaimUrl(input: {
claimLinkBase: unknown
/** 平台订单号(`/h5` 形态用) */
/** 平台订单号 */
orderNo: unknown
/** 该订单项是否有自动发货领取单元(`resolveOrderItemClaimLink().taskId` 非空即为有) */
hasAutoClaimUnit: boolean
/** token 形态链接(`resolveOrderItemClaimLink().claimUrl`) */
tokenClaimUrl?: unknown
}): string {
if (!input.hasAutoClaimUnit) {
return ''
}
const base = normalizeClaimLinkBase(input.claimLinkBase)
if (!base) {
return ''
}
if (isDouyinMashidaH5ClaimBase(base)) {
return buildConfiguredClaimUrl(base, String(input.orderNo ?? '').trim())
}
return String(input.tokenClaimUrl ?? '').trim()
}
/** 基址归一化:去首尾空白与结尾斜杠。 */
function normalizeClaimLinkBase(claimLinkBase: unknown): string {
return String(claimLinkBase ?? '')
.trim()
.replace(/\/+$/, '')
return buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined)
}
/**
@@ -254,31 +254,28 @@ async function notifyBuyerClaimLink(input: {
const claimLinkBase = String(input.config.claimLinkBase || '')
.trim()
.replace(/\/+$/, '')
if (!claimLinkBase) {
return
}
const orderNo = resolveReceiptOrderId(input.order, input.orderItem)
const receiptOrderNo = resolveReceiptOrderId(input.order, input.orderItem)
const claimOrderNo = String(input.order.platform_order_id || receiptOrderNo).trim()
// 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。
const claimLink = await resolveOrderItemClaimLink({
orderId: input.order.id,
orderNo: claimOrderNo,
orderItemId: input.orderItem.id,
claimLinkBase,
})
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
claimLinkBase,
orderNo,
orderNo: claimOrderNo,
hasAutoClaimUnit: Boolean(claimLink.taskId),
tokenClaimUrl: claimLink.claimUrl,
})
if (!claimUrl) {
logIntegration('[douyin-mashida/notify]', '该订单项没有自动发货领取单元,跳过领取链接消息', {
orderId: input.order.id,
orderItemId: input.orderItem.id,
orderNo,
orderNo: claimOrderNo,
claimLinkBase,
taskCount: claimLink.taskCount,
hasClaimUnit: Boolean(claimLink.taskId),
@@ -290,19 +287,24 @@ async function notifyBuyerClaimLink(input: {
config: input.config,
claimUrl,
sku: input.orderItem.sku_code,
orderNo,
orderNo: claimOrderNo,
productTitle: input.orderItem.sku_name,
})
if (!content) {
return
}
await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config })
await sendDouyinMashidaOrderMessage({
orderId: receiptOrderNo,
content,
config: input.config,
})
logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', {
orderId: input.order.id,
orderItemId: input.orderItem.id,
orderNo,
orderNo: claimOrderNo,
receiptOrderNo,
taskId: claimLink.taskId,
claimUrl,
})
@@ -18,13 +18,11 @@ import {
* - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。
* - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`),
* **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。
* 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token),
* 此时 `certLink` 退化为兜底。
* - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程
* (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。
* 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与
* hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl`
* (两条口径不能互相回退)。
* 买家领取链接统一为 `/h5/<订单号>`;未配 `claimLinkBase` 时使用全局领取站,
* `certLink` 仅在订单号或领取站不可用时兜底。
* - `claimLinkBase` 是**买家领取页**基址;出站链接会在其站点下统一生成
* `/h5/<平台订单号>`,历史 `/#/claim`、`/claim` 基址也会被归一化。
* 本仓库前端该页面是 `pages/claim/ClaimPage`,走 affiliate-dash 领取流程。
* - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。
*/
@@ -19,7 +19,6 @@ import {
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { isPlainObject, type JsonObject } from '../../types/json.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'
@@ -30,10 +29,7 @@ import {
syncDouyinMashidaCoupons,
verifyDouyinMashidaCoupon,
} from '../platforms/douyin-mashida/gateway-client.js'
import {
buildDouyinMashidaBuyerClaimUrl,
isDouyinMashidaH5ClaimBase,
} from '../platforms/douyin-mashida/claim-link.js'
import { buildDouyinMashidaBuyerClaimUrl } from '../platforms/douyin-mashida/claim-link.js'
import {
DOUYIN_MASHIDA_PROVIDER,
extractDouyinMashidaSubOrderId,
@@ -295,18 +291,9 @@ async function attachDouyinMashidaCertificateToWorkOrder(input: {
/**
* 解析本次同步要交给平台的 `cert_link`。
*
* 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定:
* - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理);
* - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接;
* - 拿不到可用链接 → 回退固定 `config.certLink`。
*
* 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号,
* 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service,
* 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。
*
* 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key):
* 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。
* 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。
* 券对**所有订单**都要发,买家链接统一使用 `/h5/<平台订单号>`;
* 抖音配置了 `claimLinkBase` 时使用其站点,否则使用全局领取站;
* 订单号缺失或领取站不可用时回退固定 `config.certLink`。
*
* ⚠️ 只对新单生效:已同步成功的券不会被改写。
*/
@@ -315,70 +302,24 @@ export async function resolveDouyinMashidaCertLink(input: {
orderItem: OrderItemRow
config: DouyinMashidaSourceConfig
}): Promise<string> {
const fallback = String(input.config.certLink || '').trim()
const claimLinkBase = String(input.config.claimLinkBase || '')
.trim()
.replace(/\/+$/, '')
if (!claimLinkBase) {
return fallback
}
const claimLink = await resolveOrderItemClaimLink({
orderId: input.order.id,
orderItemId: input.orderItem.id,
claimLinkBase: input.config.claimLinkBase,
})
// 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕;
// 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。
if (!claimLink.taskId && claimLink.taskCount > 0) {
logIntegration(
'[douyin-mashida/certificate]',
'该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接',
{
orderId: input.order.id,
orderItemId: input.orderItem.id,
taskCount: claimLink.taskCount,
},
{ level: 'warn' },
)
}
return resolveDouyinMashidaCertLinkFromClaim({
certLink: fallback,
claimLinkBase,
orderNo: resolveReceiptOrderId(input.order, input.orderItem),
tokenClaimUrl: claimLink.claimUrl,
certLink: input.config.certLink,
claimLinkBase: input.config.claimLinkBase,
orderNo: input.order.platform_order_id,
})
}
/**
* `cert_link` 的**纯函数**判定(便于单测)。
*
* 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址:
* - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页);
* - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。
*/
/** `cert_link` 的纯函数判定,便于单测。 */
export function resolveDouyinMashidaCertLinkFromClaim(input: {
certLink: unknown
claimLinkBase: unknown
orderNo: unknown
tokenClaimUrl?: unknown
}): string {
const fallback = String(input.certLink ?? '').trim()
const base = String(input.claimLinkBase ?? '')
.trim()
.replace(/\/+$/, '')
if (!base) {
return fallback
}
const orderNoLink = isDouyinMashidaH5ClaimBase(base)
const claimUrl = buildDouyinMashidaBuyerClaimUrl({
claimLinkBase: base,
claimLinkBase: input.claimLinkBase,
orderNo: input.orderNo,
hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()),
tokenClaimUrl: input.tokenClaimUrl,
hasAutoClaimUnit: true,
})
return claimUrl || fallback
@@ -6,6 +6,7 @@ import {
generateRedeemCertificateCode,
resolveRedeemVerifyCertNo,
} from '../../repositories/redeem-certificate-repo.js'
import { buildOrderClaimUrl } from '../claim/claim-service.js'
import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js'
import {
buildAfterVerifyMessage,
@@ -129,65 +130,60 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => {
assert.equal(isPlatformCouponVerified(null), false)
})
// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ----
// ---- 抖音 cert_link:券对所有订单都发,链接统一使用 /h5/订单号 ----
test('抖音 cert_link:/h5 形态基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
test('抖音 cert_link:/h5 基址 → 一律按订单号拼(与是否命中自动发货无关)', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: 'https://ks.khhao.com/h5',
orderNo: '6930318258716179555',
tokenClaimUrl: '',
}),
'https://ks.khhao.com/h5/6930318258716179555',
)
})
test('抖音 cert_link:/h5 基址 + 有 token → 仍按订单号拼(H5 页认订单号)', () => {
test('抖音 cert_link:/h5 基址带尾斜杠也只按订单号拼', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: 'https://ks.khhao.com/h5/',
orderNo: '6930318258716179555',
tokenClaimUrl: 'https://ks.khhao.com/h5/token-abc',
}),
'https://ks.khhao.com/h5/6930318258716179555',
)
})
test('抖音 cert_link:token 形态基址 + 有 token → 用 token 领取链接', () => {
test('抖音 cert_link:历史 /claim 基址也归一化为 /h5/订单号', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: 'https://ks.khhao.com/claim',
orderNo: '6930318258716179555',
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
}),
'https://ks.khhao.com/claim/token-abc',
'https://ks.khhao.com/h5/6930318258716179555',
)
})
test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => {
test('抖音 cert_link:订单号缺失时回退固定兑换链接', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: 'https://ks.khhao.com/claim',
orderNo: '6930318258716179555',
tokenClaimUrl: '',
claimLinkBase: 'https://ks.khhao.com/h5',
orderNo: '',
}),
'https://ks.khhao.com/redeem',
)
})
test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => {
test('抖音 cert_link:未配 claimLinkBase 时回退全局 /h5 链接', () => {
assert.equal(
resolveDouyinMashidaCertLinkFromClaim({
certLink: 'https://ks.khhao.com/redeem',
claimLinkBase: '',
orderNo: '6930318258716179555',
tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc',
}),
'https://ks.khhao.com/redeem',
buildOrderClaimUrl('6930318258716179555'),
)
})
@@ -2,7 +2,7 @@
* 内部短链服务。
*
* 注意(2026-07 起):
* - 领取主路径已统一为本站 `/#/claim/{token}`,feifei 不再为 claim 生成 shortLink。
* - 领取链接已统一为本站 `/h5/{订单号}`,feifei 不再为 claim 生成 shortLink。
* - 本模块仅保留:历史 `/s/{code}` 解析、其它潜在内部用途。
* - 新业务不要再依赖 shortLink 作为对外交付链接。
*/
@@ -46,7 +46,7 @@ export async function ensureShortLinkForTarget(
const source = String(options.source || '').trim()
if (source === SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM) {
console.warn(
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /#/claim/{token} 并在 H5 拼 uid',
'[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /h5/{订单号} 领取页',
)
}
const targetUrlHash = hashTargetUrl(normalizedTargetUrl)
@@ -835,7 +835,7 @@ export default function AdminTaskDetailPage() {
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 6 }}
>
历史/备用 Token 链接:
任务领取链接(统一 H5 订单号):
</Typography.Text>
<Space wrap align="center">
{claimUrl ? (
@@ -861,7 +861,7 @@ export default function AdminTaskDetailPage() {
disabled={!claimUrl}
onClick={copyClaimUrl}
>
复制 Token 链接
复制领取链接
</Button>
</Space>
</div>
+40 -10
View File
@@ -87,22 +87,52 @@ export function describeMissingConfigFields(config: MissingConfigCheck): string[
/**
* 面板预览用的示例订单号(与真实 16 位平台订单号形态一致)。
*/
const CLAIM_LINK_PREVIEW_TOKEN = '2628301923197355'
const CLAIM_LINK_PREVIEW_ORDER_NO = '2628301923197355'
/**
* 拼一条领取链接预览:`claimLinkBase` 去尾斜杠 + '/' + token。
* 规则与后端拼装一致(后端用配置里的 `claimLinkBase`)。
* 基址为空返回空串(后端会回退到通用兑换链接)。
* 拼一条领取链接预览:`<claimLinkBase>/<订单号>`。
* 规则与后端一致,历史 `/#/claim`、`/claim` 基址也会归一化为 `/h5/订单号`。
* 基址为空返回空串。
*/
export function buildDouyinMashidaClaimLinkPreview(
claimLinkBase: string,
token = CLAIM_LINK_PREVIEW_TOKEN,
orderNo = CLAIM_LINK_PREVIEW_ORDER_NO,
): string {
const base = String(claimLinkBase ?? '')
.trim()
.replace(/\/+$/, '')
if (!base) return ''
return `${base}/${String(token ?? '').trim()}`
const base = normalizeH5ClaimBase(claimLinkBase)
const normalizedOrderNo = String(orderNo ?? '').trim()
if (!base || !normalizedOrderNo) return ''
return `${base}/${encodeURIComponent(normalizedOrderNo)}`
}
/** 将历史 `/#/claim`、`/claim` 基址归一化为同站点 `/h5`,与后端生成口径一致。 */
function normalizeH5ClaimBase(claimLinkBase: string): string {
const raw = String(claimLinkBase ?? '').trim()
if (!raw) return ''
let url: URL
try {
url = new URL(raw)
} catch {
return ''
}
const pathname = url.pathname.replace(/\/+$/, '')
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
if (h5Match?.index !== undefined) {
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
} else if (claimMatch?.index !== undefined) {
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
} else if (pathname && pathname !== '/') {
url.pathname = `${pathname}/h5`
} else {
url.pathname = '/h5'
}
url.search = ''
url.hash = ''
return url.toString().replace(/\/+$/, '')
}
/**
+17 -9
View File
@@ -89,22 +89,30 @@ test('describeMissingConfigFields:缺单项只返回对应中文名', () => {
)
})
test('领取链接预览 = 基址去尾斜杠 + token', () => {
test('领取链接预览统一为 /h5/订单号', () => {
assert.equal(
buildDouyinMashidaClaimLinkPreview('https://x.ngrok-free.app/claim', 'tok123'),
'https://x.ngrok-free.app/claim/tok123',
buildDouyinMashidaClaimLinkPreview('https://x.ngrok-free.app/claim', '6919156612984680391'),
'https://x.ngrok-free.app/h5/6919156612984680391',
)
// 尾斜杠与前后空格要容忍
assert.equal(
buildDouyinMashidaClaimLinkPreview(' https://x.example.com/claim/ ', 'tok123'),
'https://x.example.com/claim/tok123',
buildDouyinMashidaClaimLinkPreview(' https://x.example.com/h5/ ', '6919156612984680391'),
'https://x.example.com/h5/6919156612984680391',
)
// 裸域名也允许(后端统一补 /token)
// 裸域名和历史 hash 基址都归一化为 /h5
assert.equal(
buildDouyinMashidaClaimLinkPreview('https://x.example.com', 'tok123'),
'https://x.example.com/tok123',
buildDouyinMashidaClaimLinkPreview('https://x.example.com', '6919156612984680391'),
'https://x.example.com/h5/6919156612984680391',
)
// 留空 → 不生成链接(后端回退到通用兑换链接)
assert.equal(
buildDouyinMashidaClaimLinkPreview('https://x.example.com/#/claim', '6919156612984680391'),
'https://x.example.com/h5/6919156612984680391',
)
assert.equal(
buildDouyinMashidaClaimLinkPreview('https://x.example.com/claim/tok123', '6919156612984680391'),
'https://x.example.com/h5/6919156612984680391',
)
// 留空 → 不生成预览
assert.equal(buildDouyinMashidaClaimLinkPreview(''), '')
assert.equal(buildDouyinMashidaClaimLinkPreview(' '), '')
})
+1 -1
View File
@@ -97,7 +97,7 @@ your_secret_keybuyNum=1&callbackUrl=https://cb.example.com/notify/91/order&maxAm
4. 若 `productNo` 已映射到 affiliate-dash 履约商品,则创建履约任务;
5. 若 `productNo` 尚未配置,则订单进入后台“待补全”队列,由运营补齐履约配置后重试生成任务;
6. 若传入 `maxAmount`,则校验成本是否超限;
7. 履约任务生成后,生成当前项目领取链接,例如:`https://你的域名/#/claim/{token}`;
7. 履约任务生成后,生成当前项目领取链接,例如:`https://你的域名/h5/{订单号}`;
8. 首次响应返回处理中状态,由 `91卡券` 后续调用查询订单接口获取最终卡密内容。
## 10. 响应参数
+6 -6
View File
@@ -96,7 +96,7 @@ your_secret_keyorderNo=P91KS202605040001&timestamp=1777867260&version=1.0your_se
| `failReason` | string | 失败时建议返回 | 失败原因。 |
| `orderCost` | decimal(14,4) | 成功时建议返回 | 订单总成本,单位:元。 |
| `cards` | string | `orderStatus = 20` 时必须 | 卡密数据加密串。当前项目通过该字段返回领取链接。 |
| `cards[0].cardNo` | string | 成功时必须 | 最终交付的领取链接,格式为 `https://你的域名/#/claim/{token}`。 |
| `cards[0].cardNo` | string | 成功时必须 | 最终交付的领取链接,格式为 `https://你的域名/h5/{订单号}`。 |
| `cards[0].cardPwd` | string | 可选 | 当前场景固定为空字符串。 |
| `cards[0].expireTime` | string | 可选 | 领取链接过期时间,支持 `yyyy-MM-dd HH:mm:ss` 或 10 位秒级 Unix 时间戳。 |
| `cards[0].jumpLink` | string | 可选 | 与 `cardNo` 保持一致,用于兼容链接展示场景。 |
@@ -115,15 +115,15 @@ your_secret_keyorderNo=P91KS202605040001&timestamp=1777867260&version=1.0your_se
```json
[
{
"cardNo": "https://你的域名/#/claim/abc123xyz",
"cardNo": "https://你的域名/h5/6917533224570258991",
"cardPwd": "",
"expireTime": "2026-05-05 12:00:00",
"jumpLink": "https://你的域名/#/claim/abc123xyz"
"jumpLink": "https://你的域名/h5/6917533224570258991"
}
]
```
若 `buyNum > 1`,则 `cards` 原始结构中返回多个卡项,每个卡项对应一个独立领取链接。
若 `buyNum > 1`,则 `cards` 原始结构中返回多个卡项;由于对外链接按订单号生成,每个卡项会返回同一个 `/h5/{订单号}` 链接,领取页按任务顺序逐个推进。
## 12. 响应示例
@@ -168,10 +168,10 @@ your_secret_keyorderNo=P91KS202605040001&timestamp=1777867260&version=1.0your_se
```json
[
{
"cardNo": "https://你的域名/#/claim/abc123xyz",
"cardNo": "https://你的域名/h5/6917533224570258991",
"cardPwd": "",
"expireTime": "2026-05-05 12:00:00",
"jumpLink": "https://你的域名/#/claim/abc123xyz"
"jumpLink": "https://你的域名/h5/6917533224570258991"
}
]
```
+2 -2
View File
@@ -237,9 +237,9 @@ Content-Type: application/json;charset=utf-8
如果 `buyNum > 1`:
- 每个数量生成一个独立领取链接;
- 每个数量生成一个独立履约任务;
- `cards` 中放多个卡项;
- 每个 `cardNo` 对应一个独立链接。
- 每个 `cardNo` 统一返回同一个 `/h5/<订单号>` 链接,领取页按任务顺序推进。
## 9. 查询成功响应示例
+2 -2
View File
@@ -15,7 +15,7 @@
2. 我方将订单作为独立接入平台订单写入当前项目;
3. 已配置商品自动走现有 affiliate-dash 履约链路;
4. 未配置商品进入后台待补全队列,可手动补齐履约信息后重试;
5. 我方生成领取链接 `https://你的域名/#/claim/{token}`;
5. 我方生成领取链接 `https://你的域名/h5/{订单号}`;
6. `91卡券` 调用查询订单接口;
7. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。
@@ -246,7 +246,7 @@ Node 侧实现等价为:
- 内部创建 `N` 个 task;
- 查询成功时返回 `N` 个 card;
- 每个 card 对应一个独立领取链接。
- 每个 card 对应一个独立履约任务;对外链接统一为同一个 `/h5/<订单号>`,领取页按任务顺序推进。
## 10. 商品匹配设计
@@ -74,7 +74,7 @@ kuaishou-91 进单
这里的卡密内容继续保持为我方统一领取链接:
```text
claimUrl = https://你的域名/#/claim/{token}
claimUrl = https://你的域名/h5/{订单号}
```
不要直接把 affiliate-dash 的绑定链接返回给 91。原因是领取页需要统一处理:
@@ -301,7 +301,7 @@ function RedeemApplication() {
**⚠️ `cert_link` 必须做成配置项**(`app_config` 里的 `certLink`),不要硬编码:链接一旦通过 `syncV2` 发给平台就**收不回来**(老券身上永远挂着老链接)。以后换域名时,**老域名必须继续可跳**(或用 301 兜住),否则老券全部点不开。
- 备注:现有领取链接仍是 hash 式 `https://域名/#/claim`(`CLAIM_BASE_URL` 配置)—— 两套并存,本次**不动老链路**。
- 备注:该方案落笔时领取链接仍是 hash 式 `https://域名/#/claim`;现已统一出站为 `https://域名/h5/<平台订单号>`,hash token 路由仅保留历史入站兼容。
---
+2 -2
View File
@@ -163,7 +163,7 @@ npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --s
3. 填真实凭据 面板:uid = 抖店商户号,secret = 真实用户秘钥
(baseUrl 保持 https://dy-openplatform-api.wanmahui.com)
4. 皮肤领取页两项配置 面板:
claimLinkBase = https://<域名>/claim 皮肤订单领取页基址
claimLinkBase = https://<域名>/h5 皮肤订单领取页基址
messageTemplate = 您的订单已发货,请点击领取:{link}
⚠️ claimLinkBase 必须是**路径式**(不要带 #):
#/claim 是打手领取页,配错会把买家带到打手流程
@@ -175,7 +175,7 @@ npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --s
POST /api/v1/open/douyin-mashida/message 收到推送
[douyin-mashida/certificate] 券码已同步 syncV2 成功
fulfillment_tasks / claim_tokens 已建任务与领取 token
8. 手机打开买家领取链接 https://<域名>/claim/<token>(首次要过 ngrok 警示页)
8. 手机打开买家领取链接 https://<域名>/h5/<平台订单号>(首次要过 ngrok 警示页)
→ 看到物品图片 → 填游戏账号 → 领取 → 本地看到 verifyV3
9. 对账 orders / order_items / redeem_certificates / fulfillment_tasks 四张表
```
@@ -38,13 +38,11 @@
## 3. 链接必须是**路径式**,且只有一个
- 领取页走**独立 BrowserRouter 分支**的 `/claim/<token>`(照 `/redeem` 的做法,见 `apps/frontend/src/App.tsx`),
**不带 `#`**;主站里 hash 形式的 `#/claim/:token` 仍保留(pathname 为 `/`,两套互不影响)。
- 链接由抖音配置的 `claimLinkBase` 拼:`claimLinkBase`(去尾斜杠)+ `/` + token。
**绝不回退** `buildClaimUrl` / `orders.claimBaseUrl`(那是 hash 的打手领取页)。
- 同一订单项若有多个履约单元(多个 token),固定取 task id 最小的那个并打 warn,
保证「同一张券每次都解析出同一条链接」。
- 老订单的券里已经写死了 `/redeem` 链接(发出去收不回来),旧页面**保留可用**。
- 对外领取链接统一走**独立 BrowserRouter 分支**的 `/h5/<平台订单号>`(见 `apps/frontend/src/App.tsx`),**不带 `#`**。
- 抖音配置了 `claimLinkBase` 时沿用其站点,未配置时使用全局 `orders.claimBaseUrl`;历史 `/#/claim`、`/claim` 基址会自动归一化到 `/h5`。
- claim token 仍按履约单元生成,但只用于判断“该订单项是否已有自动发货单元”,不再拼进对外链接。
- 同一订单有多个履约单元时共用一个订单号链接,领取页会按任务顺序推进。
- 老订单的券里已经写死的 `/redeem` 或 token 链接(发出去收不回来),旧页面**保留兼容**。
## 4. 商品图怎么来
@@ -14,7 +14,7 @@
2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。
3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。
4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。
5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。
5. 对外领取链接统一为 `<领取站>/h5/<平台订单号>`;`claim_tokens` 仍按任务生成(`task_id` 唯一),但 token 只用于内部领取单元判定,不再拼进对外链接。
6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。
7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。
8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。
@@ -240,8 +240,8 @@ flowchart TD
- `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`;
- 未支付 → `pending_payment`。
3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。
4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。
5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。
4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`);token 只用于内部领取单元判定,不再出现在对外链接中。
5. 所有来源对外统一生成 `claimUrl = <领取站>/h5/<平台订单号>`;历史 `/#/claim/:token`、`/claim/:token` 仅保留入站兼容。
6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。
### 3.5 affiliate-dash 自动履约
@@ -262,8 +262,8 @@ flowchart TD
| 入口 | 路由 | 用途 |
| --- | --- | --- |
| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 |
| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` |
| 主站领取页(历史兼容) | `/#/claim/:token`、`/claim/:token` | 仅接收历史 token 链接,按 `executorKey` 渲染不同步骤 |
| H5 领取页(统一出站) | `/h5/:orderNo`(BrowserRouter 分支) | 所有来源按订单号承接,`/api/v1/claim/h5/...` |
| 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 |
| 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |