From 4dc2f5ca90e498143e4ada51e750bc8bded30f05 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 22:11:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(claim):=20=E7=BB=9F=E4=B8=80=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=B9=B0=E5=AE=B6=E9=A2=86=E5=8F=96=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E4=B8=BA=20H5=20=E8=AE=A2=E5=8D=95=E5=8F=B7=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 2 +- .env.server.example | 2 +- README.md | 2 +- apps/backend/src/config/defaults.ts | 2 +- apps/backend/src/config/env-overrides.test.ts | 2 +- apps/backend/src/config/env-overrides.ts | 2 +- apps/backend/src/routes/short-links.ts | 2 +- .../src/services/admin/admin-read-service.ts | 32 ++---- .../douyin-mashida-certificate-service.ts | 14 ++- .../src/services/admin/write-helpers.ts | 6 +- .../src/services/admin/write/task-actions.ts | 34 ++---- .../src/services/claim/claim-service.test.ts | 40 ++++--- .../src/services/claim/claim-service.ts | 103 ++++++++++++------ .../claim/kuaishou-cloud-claim-context.ts | 8 +- .../src/services/dev-mock/dev-mock-service.ts | 16 +-- .../fulfillment/delivery-link-service.test.ts | 6 +- .../executors/affiliate-dash-executor.ts | 6 +- .../services/fulfillment/executors/types.ts | 5 +- .../src/services/open-91/protocol.test.ts | 6 +- .../douyin-mashida/claim-link.test.ts | 41 +------ .../platforms/douyin-mashida/claim-link.ts | 52 +-------- .../douyin-mashida/notify-service.ts | 24 ++-- .../douyin-mashida/source-config-service.ts | 12 +- .../services/redeem/certificate-service.ts | 79 ++------------ .../services/redeem/redeem-service.test.ts | 26 ++--- .../short-links/short-link-service.ts | 4 +- .../src/pages/admin/AdminTaskDetailPage.tsx | 4 +- apps/frontend/src/utils/douyin-mashida.ts | 50 +++++++-- apps/frontend/tests/douyin-mashida.test.ts | 26 +++-- docs/91卡券/3.异步卡密下单.md | 2 +- docs/91卡券/4.查询订单接口.md | 12 +- docs/91卡券/5.当前项目对接配置.md | 4 +- docs/91卡券/6.开发设计.md | 4 +- docs/履约配置/多发货平台与电子凭证整体链路.md | 2 +- docs/抖音-码速达/05-最小接入方案分析.md | 2 +- docs/抖音-码速达/08-公网联调.md | 4 +- docs/抖音-码速达/09-皮肤领取流程.md | 12 +- docs/项目全流程与数据库设计梳理-2026-10-23.md | 10 +- 38 files changed, 298 insertions(+), 362 deletions(-) diff --git a/.env.mac-docker.example b/.env.mac-docker.example index a21954ec..86d11875 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -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 diff --git a/.env.server.example b/.env.server.example index 3ee4594d..5470e05a 100644 --- a/.env.server.example +++ b/.env.server.example @@ -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 diff --git a/README.md b/README.md index a93b6fd9..29184edc 100644 --- a/README.md +++ b/README.md @@ -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` | 后台登录态签名密钥 | diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 36c4feea..437296df 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -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, }, diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 9b29bdfe..a53db9de 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -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 表示领取链接长期有效', () => { diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 46fcf676..d3502261 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -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` } } diff --git a/apps/backend/src/routes/short-links.ts b/apps/backend/src/routes/short-links.ts index 9a42d0c0..c25265ad 100644 --- a/apps/backend/src/routes/short-links.ts +++ b/apps/backend/src/routes/short-links.ts @@ -1,6 +1,6 @@ /** * 历史短链只读跳转入口(/s/:code)。 - * 新 claim 交付请使用本站 /#/claim/{token},勿再为 feifei 生成 shortLink。 + * 新 claim 交付请使用本站 /h5/{订单号},勿再为 feifei 生成 shortLink。 */ import { Router } from 'express' diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 8e51c719..40b9558d 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -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 | null | undefined, + order: Pick | 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 { diff --git a/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts index 941f47e1..891ccb60 100644 --- a/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts +++ b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts @@ -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, diff --git a/apps/backend/src/services/admin/write-helpers.ts b/apps/backend/src/services/admin/write-helpers.ts index 8136eaea..b23848ff 100644 --- a/apps/backend/src/services/admin/write-helpers.ts +++ b/apps/backend/src/services/admin/write-helpers.ts @@ -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 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, diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index 8c5cd951..19424623 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -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, token: string, ): Promise { - 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 diff --git a/apps/backend/src/services/claim/claim-service.test.ts b/apps/backend/src/services/claim/claim-service.test.ts index a5758d9c..4a2649cb 100644 --- a/apps/backend/src/services/claim/claim-service.test.ts +++ b/apps/backend/src/services/claim/claim-service.test.ts @@ -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 的单元', () => { diff --git a/apps/backend/src/services/claim/claim-service.ts b/apps/backend/src/services/claim/claim-service.ts index 4f7621d7..37bb2c70 100644 --- a/apps/backend/src/services/claim/claim-service.ts +++ b/apps/backend/src/services/claim/claim-service.ts @@ -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>> claimUrl: string } -export async function createTaskClaimToken(taskId: number | string): Promise { +export async function createTaskClaimToken( + taskId: number | string, + orderNo?: unknown, +): Promise { 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/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> | 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 { 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, } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 0b6b7450..cec5c0b3 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -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: '', diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index a27d7904..d3f08e21 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -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 会给出二维码'] diff --git a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts index 5d9f3668..cb44b244 100644 --- a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts +++ b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts @@ -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', }) }) diff --git a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts index 7459a1ed..617dea4a 100644 --- a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts @@ -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 Promise - createTaskClaimToken: (taskId: number | string) => Promise<{ + createTaskClaimToken: ( + taskId: number | string, + orderNo?: unknown, + ) => Promise<{ token: string expired_at: string | null [key: string]: unknown diff --git a/apps/backend/src/services/open-91/protocol.test.ts b/apps/backend/src/services/open-91/protocol.test.ts index 8e44d467..c6394434 100644 --- a/apps/backend/src/services/open-91/protocol.test.ts +++ b/apps/backend/src/services/open-91/protocol.test.ts @@ -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', }, ) }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts index 905ff851..baa62e25 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts @@ -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({ diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts index a95804b8..35964d7e 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts @@ -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) } /** diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index 2b6316bc..10eb2258 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -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, }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index 4ee7fc5d..e2cc541a 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -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 只落库留痕、不产生副作用。 */ diff --git a/apps/backend/src/services/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index 4b7451b3..26d8b200 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -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 { - 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 diff --git a/apps/backend/src/services/redeem/redeem-service.test.ts b/apps/backend/src/services/redeem/redeem-service.test.ts index e5f7f3de..af1a2902 100644 --- a/apps/backend/src/services/redeem/redeem-service.test.ts +++ b/apps/backend/src/services/redeem/redeem-service.test.ts @@ -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'), ) }) diff --git a/apps/backend/src/services/short-links/short-link-service.ts b/apps/backend/src/services/short-links/short-link-service.ts index 5f86fdbf..5272d1b1 100644 --- a/apps/backend/src/services/short-links/short-link-service.ts +++ b/apps/backend/src/services/short-links/short-link-service.ts @@ -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) diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index b491658f..9cd35624 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -835,7 +835,7 @@ export default function AdminTaskDetailPage() { type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }} > - 历史/备用 Token 链接: + 任务领取链接(统一 H5 订单号): {claimUrl ? ( @@ -861,7 +861,7 @@ export default function AdminTaskDetailPage() { disabled={!claimUrl} onClick={copyClaimUrl} > - 复制 Token 链接 + 复制领取链接 diff --git a/apps/frontend/src/utils/douyin-mashida.ts b/apps/frontend/src/utils/douyin-mashida.ts index 2b590572..332257c2 100644 --- a/apps/frontend/src/utils/douyin-mashida.ts +++ b/apps/frontend/src/utils/douyin-mashida.ts @@ -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`)。 - * 基址为空返回空串(后端会回退到通用兑换链接)。 + * 拼一条领取链接预览:`/<订单号>`。 + * 规则与后端一致,历史 `/#/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(/\/+$/, '') } /** diff --git a/apps/frontend/tests/douyin-mashida.test.ts b/apps/frontend/tests/douyin-mashida.test.ts index 118ca90b..220435a6 100644 --- a/apps/frontend/tests/douyin-mashida.test.ts +++ b/apps/frontend/tests/douyin-mashida.test.ts @@ -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(' '), '') }) diff --git a/docs/91卡券/3.异步卡密下单.md b/docs/91卡券/3.异步卡密下单.md index 882876d1..c14942d1 100644 --- a/docs/91卡券/3.异步卡密下单.md +++ b/docs/91卡券/3.异步卡密下单.md @@ -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. 响应参数 diff --git a/docs/91卡券/4.查询订单接口.md b/docs/91卡券/4.查询订单接口.md index 5e920343..3e15bf2d 100644 --- a/docs/91卡券/4.查询订单接口.md +++ b/docs/91卡券/4.查询订单接口.md @@ -96,7 +96,7 @@ your_secret_keyorderNo=P91KS202605040001×tamp=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×tamp=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×tamp=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" } ] ``` diff --git a/docs/91卡券/5.当前项目对接配置.md b/docs/91卡券/5.当前项目对接配置.md index c42140b0..19651e3f 100644 --- a/docs/91卡券/5.当前项目对接配置.md +++ b/docs/91卡券/5.当前项目对接配置.md @@ -237,9 +237,9 @@ Content-Type: application/json;charset=utf-8 如果 `buyNum > 1`: -- 每个数量生成一个独立领取链接; +- 每个数量生成一个独立履约任务; - `cards` 中放多个卡项; -- 每个 `cardNo` 对应一个独立链接。 +- 每个 `cardNo` 统一返回同一个 `/h5/<订单号>` 链接,领取页按任务顺序推进。 ## 9. 查询成功响应示例 diff --git a/docs/91卡券/6.开发设计.md b/docs/91卡券/6.开发设计.md index e87b6a63..ddc82946 100644 --- a/docs/91卡券/6.开发设计.md +++ b/docs/91卡券/6.开发设计.md @@ -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. 商品匹配设计 diff --git a/docs/履约配置/多发货平台与电子凭证整体链路.md b/docs/履约配置/多发货平台与电子凭证整体链路.md index 7351ffce..1686db0a 100644 --- a/docs/履约配置/多发货平台与电子凭证整体链路.md +++ b/docs/履约配置/多发货平台与电子凭证整体链路.md @@ -74,7 +74,7 @@ kuaishou-91 进单 这里的卡密内容继续保持为我方统一领取链接: ```text -claimUrl = https://你的域名/#/claim/{token} +claimUrl = https://你的域名/h5/{订单号} ``` 不要直接把 affiliate-dash 的绑定链接返回给 91。原因是领取页需要统一处理: diff --git a/docs/抖音-码速达/05-最小接入方案分析.md b/docs/抖音-码速达/05-最小接入方案分析.md index a5c09b00..6deaa605 100644 --- a/docs/抖音-码速达/05-最小接入方案分析.md +++ b/docs/抖音-码速达/05-最小接入方案分析.md @@ -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 路由仅保留历史入站兼容。 --- diff --git a/docs/抖音-码速达/08-公网联调.md b/docs/抖音-码速达/08-公网联调.md index 352d7ed6..12b721e6 100644 --- a/docs/抖音-码速达/08-公网联调.md +++ b/docs/抖音-码速达/08-公网联调.md @@ -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/(首次要过 ngrok 警示页) +8. 手机打开买家领取链接 https://<域名>/h5/<平台订单号>(首次要过 ngrok 警示页) → 看到物品图片 → 填游戏账号 → 领取 → 本地看到 verifyV3 9. 对账 orders / order_items / redeem_certificates / fulfillment_tasks 四张表 ``` diff --git a/docs/抖音-码速达/09-皮肤领取流程.md b/docs/抖音-码速达/09-皮肤领取流程.md index 946f9b39..f69b20cb 100644 --- a/docs/抖音-码速达/09-皮肤领取流程.md +++ b/docs/抖音-码速达/09-皮肤领取流程.md @@ -38,13 +38,11 @@ ## 3. 链接必须是**路径式**,且只有一个 -- 领取页走**独立 BrowserRouter 分支**的 `/claim/`(照 `/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. 商品图怎么来 diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md index 62d2cedb..eee1b263 100644 --- a/docs/项目全流程与数据库设计梳理-2026-10-23.md +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -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` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |