feat(douyin-mashida): 皮肤自动发货(按内部 SKU 分流 + 领取链接 + 买家领取接口)
抖音码速达的皮肤类商品改为「自动发货 + 领取链接」:买家不用输订单号,点链接直接领。
**分流(按内部 SKU)**
- 平台闸门放行 `provider='douyin_mashida' && platform='doudian'`,走**独立分支**
(原有 91/快手路径一行未动,避免外溢)
- 判据:内部 SKU(`sku_order_list[].code`,即 order_items.sku_code)命中权威表
`AFFILIATE_DASH_SKU_MAPPING` → 新执行器 `douyin_mashida_skin`;未命中 → `manual_dispatch`
- 皮肤判定与商品图同源(都用那张权威表),保证「能路由 ⇒ 有图」
- 履约路由新增 `internal_sku` 匹配维度(`rules[].internalSku`,NFKC+去空白+小写,exact/contains);
旧规则缺 `matchField` 时默认 `product_name`,**归一化/优先级/回退顺序完全不变**
**执行器与领取 token**
- 新增 `douyin-mashida-skin-executor`:建 claim token、置 `link_generated/pending_claim`,
`resolveDeliveryLink` 返回本站路径式领取链接;注册进 registry、planner 分支、
bootstrap 种子 profile(`抖音皮肤自动发货`);进 `ROUTABLE_EXECUTOR_KEYS`
- token 粒度 = 一个履约单元一个(`claim_tokens.task_id` 唯一);一单多项时
cert_link/买家消息取 task id 最小的那个并告警
**cert_link 与发消息**
- 皮肤订单且该订单项已有 token 且配了 `claimLinkBase` → `cert_link = claimLinkBase/token`;
其它情况一律用 `certLink`(老行为不变)
- 领取链接**只认** `claimLinkBase`,绝不回退 `buildClaimUrl`/`orders.claimBaseUrl`
(那是 hash 形式的打手领取页,语义不同)
- 付款建单+发券成功后按 `messageTemplate`(`{link}/{sku}/{orderNo}/{productTitle}`)
经 `order.message` 发给买家;best-effort,失败只记 warn
**买家领取接口**
- `GET/POST /api/v1/claim-skin/:token`(限流、公开);详情含内部SKU/引流标题/商品图/
数量/金额/支付时间/动态表单字段;token 失效给明确 errorCode(404/410/409),不打 500
- 提交账号:先核销平台券再落记录(避免「记账了但券没核销」的资损),已提交幂等
**隐私**
- 买家游戏账号落 `fulfillment_tasks.claim_submission_json`(迁移 096),
**不进** `work_orders.material_json`(那是发给打手的素材);详情接口不返回券码明文/手机号/地址/open_id
This commit is contained in:
@@ -31,13 +31,16 @@ router.post(
|
||||
action: 'platform_douyin_mashida_config_updated',
|
||||
targetType: 'platform_config',
|
||||
targetId: 'douyin_mashida',
|
||||
// 审计只记摘要,绝不写密钥
|
||||
// 审计只记摘要,绝不写密钥;消息模板正文属于运营文案,同样只记开关与是否配置
|
||||
data: {
|
||||
enabled: config.enabled !== false,
|
||||
hasUid: Boolean(config.uid),
|
||||
hasSecret: Boolean(config.hasSecret),
|
||||
hasCertLink: Boolean(config.certLink),
|
||||
hasAfterVerifyLink: Boolean(config.afterVerifyLink),
|
||||
hasClaimLinkBase: Boolean(config.claimLinkBase),
|
||||
hasMessageTemplate: Boolean(config.messageTemplate),
|
||||
claimMessageEnabled: config.claimMessageEnabled !== false,
|
||||
messageType: Number(config.messageType || 0),
|
||||
handledTopicCount: Array.isArray(config.handledTopics) ? config.handledTopics.length : 0,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router } from 'express'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import { getSkinClaimDetail, submitSkinClaim } from '../services/claim/claim-skin-service.js'
|
||||
import { buildMessageSuccessPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
/**
|
||||
* 抖音码速达「皮肤自动发货」买家领取页(公开接口,无需登录)。
|
||||
*
|
||||
* 响应契约(前端并行开发中):
|
||||
* - 成功:`{ code: 0, message, data }`
|
||||
* - 失败:仓库标准错误体 `{ code: 1, msg, message, errorCode, time, data: null }`,
|
||||
* 前端按 `errorCode` 展示中文提示(`claim_token_not_found` / `claim_token_expired` /
|
||||
* `claim_task_closed` / `claim_skin_task_invalid` / `claim_skin_game_account_*`)。
|
||||
*
|
||||
* 限流与 `routes/claims.ts` 同款:按客户端 IP + token 分桶。
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
const claimSkinReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claimSkin:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
const claimSkinWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claimSkin:write',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.get('/:token', claimSkinReadRateLimit, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getSkinClaimDetail(req.params.token)
|
||||
res.json(buildMessageSuccessPayload(data, 'success'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询领取详情失败', '[claim-skin/:token]', { messageAlias: true })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:token', claimSkinWriteRateLimit, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = (req.body || {}) as Record<string, unknown>
|
||||
const data = await submitSkinClaim(req.params.token, { gameAccount: body.gameAccount })
|
||||
res.json(buildMessageSuccessPayload(data, '领取成功'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '领取失败,请稍后重试', '[claim-skin/:token]', {
|
||||
messageAlias: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user