fix(claim): 统一所有买家领取链接为 H5 订单号格式
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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` | 后台登录态签名密钥 |
|
||||
|
||||
@@ -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 表示领取链接长期有效', () => {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(' '), '')
|
||||
})
|
||||
|
||||
@@ -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. 响应参数
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -237,9 +237,9 @@ Content-Type: application/json;charset=utf-8
|
||||
|
||||
如果 `buyNum > 1`:
|
||||
|
||||
- 每个数量生成一个独立领取链接;
|
||||
- 每个数量生成一个独立履约任务;
|
||||
- `cards` 中放多个卡项;
|
||||
- 每个 `cardNo` 对应一个独立链接。
|
||||
- 每个 `cardNo` 统一返回同一个 `/h5/<订单号>` 链接,领取页按任务顺序推进。
|
||||
|
||||
## 9. 查询成功响应示例
|
||||
|
||||
|
||||
+2
-2
@@ -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 路由仅保留历史入站兼容。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user