feat(douyin-mashida): 新增抖音券核销后台界面(列表/核销/冲正/同步/对账/作废)

抖音订单并非纯自动发货:内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING 才走自动通道,
未命中显式回落人工,且核销依赖买家自己到 /redeem 输订单号。此前核销侧在后台完全
不可见:自动核销是 void .catch(warn) 只落日志,cancelVerify 出网方法已就绪但没有入口。

照快手电子凭证的骨架做一套抖音版(两张表两套状态机,不复用):
- 券码状态、平台侧状态、同步错误全部可视化,支持只看同步失败
- 核销/冲正/重新同步/平台对账/重发券码/作废,每个写操作都带审计
- 券码列显示「核销实际会用的券码」(platform_cert_no 优先),平台改过名时额外标注
- 冲正先打平台 cancelVerify 再回退本地,避免本地与平台状态漂移
This commit is contained in:
yml2213
2026-10-12 19:45:06 +08:00
parent 0dd26da4ee
commit 5a73fb74a9
14 changed files with 1896 additions and 0 deletions
@@ -61,6 +61,137 @@ export function generateRedeemCertificateCode(
return `${REDEEM_CERT_CODE_PREFIX}${encodeCrockfordBase32(randomBytes(CERT_CODE_RANDOM_BYTES))}`
}
export type RedeemCertificateAdminListQuery = {
/** 券码(我方 cert_no 或平台侧 platform_cert_no,精确匹配,大小写不敏感) */
certNo?: string | undefined
/** 平台订单号(子/父订单号任一匹配) */
orderId?: string | undefined
/** 本地订单 ID */
localOrderId?: number | string | undefined
/** 本地订单项 ID */
orderItemId?: number | string | undefined
/** 券状态:issued / synced / verified / invalid */
status?: string | undefined
/** 同步状态:pending / success / failed */
syncStatus?: string | undefined
page?: number | string | undefined
pageSize?: number | string | undefined
}
/** 后台列表行:券 + 关联的任务号 / 商品编码,方便运营认货。 */
export type RedeemCertificateAdminListRow = RedeemCertificateRow & {
task_no: string
sku_code: string
sku_name: string
}
export async function listRedeemCertificatesForAdmin(
input: RedeemCertificateAdminListQuery = {},
): Promise<{
items: RedeemCertificateAdminListRow[]
total: number
page: number
pageSize: number
}> {
const page = normalizePositiveLimit(input.page, 1)
const pageSize = Math.min(normalizePositiveLimit(input.pageSize, 20), 100)
const where: string[] = ["c.provider = 'douyin_mashida'"]
const params: unknown[] = []
const certNo = String(input.certNo || '').trim()
if (certNo) {
params.push(Array.from(new Set([certNo, certNo.toUpperCase()])))
where.push(
`(c.cert_no = ANY($${params.length}::text[]) OR c.platform_cert_no = ANY($${params.length}::text[]))`,
)
}
const orderId = String(input.orderId || '').trim()
if (orderId) {
params.push(orderId)
where.push(`(c.order_id = $${params.length} OR c.parent_order_id = $${params.length})`)
}
const localOrderId = Number(input.localOrderId || 0)
if (Number.isFinite(localOrderId) && localOrderId > 0) {
params.push(Math.trunc(localOrderId))
where.push(`c.local_order_id = $${params.length}`)
}
const orderItemId = Number(input.orderItemId || 0)
if (Number.isFinite(orderItemId) && orderItemId > 0) {
params.push(Math.trunc(orderItemId))
where.push(`c.order_item_id = $${params.length}`)
}
const status = String(input.status || '')
.trim()
.toLowerCase()
if (status) {
params.push(status)
where.push(`c.status = $${params.length}`)
}
const syncStatus = String(input.syncStatus || '')
.trim()
.toLowerCase()
if (syncStatus) {
params.push(syncStatus)
where.push(`c.sync_status = $${params.length}`)
}
const whereSql = `WHERE ${where.join(' AND ')}`
const totalResult = await query<{ total: string | number }>(
`
SELECT COUNT(*) AS total
FROM redeem_certificates c
${whereSql}
`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const listResult = await query<RedeemCertificateAdminListRow>(
`
SELECT
c.*,
COALESCE(ft.task_no, '') AS task_no,
COALESCE(oi_task.sku_code, oi_order.sku_code, '') AS sku_code,
COALESCE(oi_task.sku_name, oi_order.sku_name, '') AS sku_name
FROM redeem_certificates c
LEFT JOIN LATERAL (
SELECT ft.task_no, ft.order_item_id
FROM fulfillment_tasks ft
WHERE c.order_item_id IS NOT NULL
AND ft.order_item_id = c.order_item_id
ORDER BY ft.id ASC
LIMIT 1
) ft ON true
LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id
LEFT JOIN LATERAL (
SELECT oi.sku_code, oi.sku_name
FROM order_items oi
WHERE c.local_order_id IS NOT NULL
AND oi.order_id = c.local_order_id
ORDER BY oi.id ASC
LIMIT 1
) oi_order ON true
${whereSql}
ORDER BY c.updated_at DESC, c.id DESC
LIMIT $${params.length + 1}
OFFSET $${params.length + 2}
`,
listParams,
)
return {
items: listResult.rows,
total: Number(totalResult.rows[0]?.total || 0) || 0,
page,
pageSize,
}
}
export async function findRedeemCertificateByOrder(input: {
provider: string
orderId: string
@@ -262,6 +393,51 @@ export async function invalidateRedeemCertificatesByLocalOrder(input: {
return result.rows
}
/**
* 后台冲正(`coupons.cancelVerify` 成功后)把本地状态退回「已同步未核销」。
*
* 只回退 `verified` 的券:issued/synced 本来就没核销,invalid 是退款作废的终止态,
* 冲正动不了平台侧的作废状态,改本地反而会造成「本地可用、平台不可用」的状态漂移。
*/
export async function markRedeemCertificateVerifyCancelled(
id: number | string,
): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET status = 'synced', verified_at = NULL, updated_at = NOW()
WHERE id = $1 AND status = 'verified'
RETURNING *
`,
[Number(id)],
)
return result.rows[0] || null
}
/** 后台作废:把未核销的券打入 `invalid`(已核销的留给售后/冲正处理)。 */
export async function markRedeemCertificateInvalid(input: {
id: number | string
reason: string
}): Promise<RedeemCertificateRow | null> {
const result = await query<RedeemCertificateRow>(
`
UPDATE redeem_certificates
SET status = 'invalid',
sync_error = CASE WHEN sync_error = '' THEN $2 ELSE sync_error END,
updated_at = NOW()
WHERE id = $1 AND status <> 'verified' AND status <> 'invalid'
RETURNING *
`,
[Number(input.id), String(input.reason || '').slice(0, 500)],
)
return result.rows[0] || null
}
function normalizePositiveLimit(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback
}
function encodeCrockfordBase32(bytes: Uint8Array): string {
let bits = 0
let value = 0
+2
View File
@@ -4,6 +4,7 @@ import authRouter from './admin/auth.js'
import auditLogsRouter from './admin/audit-logs.js'
import dashboardRouter from './admin/dashboard.js'
import devMockRouter from './admin/dev-mock.js'
import douyinMashidaRouter from './admin/douyin-mashida.js'
import financeRouter from './admin/finance.js'
import filesRouter from './admin/files.js'
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
@@ -30,6 +31,7 @@ router.use(rbacRouter)
router.use(auditLogsRouter)
router.use(platformConfigRouter)
router.use(kuaishouIndustryRouter)
router.use(douyinMashidaRouter)
router.use(ordersRouter)
router.use(notificationsRouter)
router.use(realtimeRouter)
@@ -0,0 +1,188 @@
import { Router } from 'express'
import {
cancelAdminDouyinMashidaCertificateVerify,
checkAdminDouyinMashidaCertificate,
fetchAdminDouyinMashidaOrderDetail,
invalidateAdminDouyinMashidaCertificate,
listAdminDouyinMashidaCertificates,
resendAdminDouyinMashidaCertificate,
syncAdminDouyinMashidaCertificate,
verifyAdminDouyinMashidaCertificate,
} from '../../services/admin/douyin-mashida-certificate-service.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
import type {
AdminDouyinMashidaCertificateActionRouteBody,
AdminDouyinMashidaCertificateListRouteQuery,
} from '../../types/admin/route-inputs.js'
/**
* 后台「抖音券核销」:与 `admin/kuaishou-industry.ts` 同型的运营工具路由,
* 操作对象换成我们自发的兑换券(`redeem_certificates`,provider=douyin_mashida)。
*
* 这里每个写操作都带审计:**<|hy_place▁holder▁no▁813|>码/核销/冲正会直接改变平台侧资金相关状态**,
* 没有留痕等于出事没法追溯。
*/
const router = Router()
const MANAGE_ROLES = ['admin', 'operator', 'support']
router.get(
'/douyin-mashida/certificates',
createJsonHandler(
(req) =>
listAdminDouyinMashidaCertificates(req.query as AdminDouyinMashidaCertificateListRouteQuery),
{
successMessage: 'ok',
errorMessage: '查询抖音兑换券失败',
scope: '[admin/douyin-mashida/certificates]',
},
),
)
router.post(
'/douyin-mashida/certificates/verify',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
verifyAdminDouyinMashidaCertificate(req.body as AdminDouyinMashidaCertificateActionRouteBody),
{
successMessage: '券码已核销',
errorMessage: '手动核销券码失败',
scope: '[admin/douyin-mashida/certificates/verify]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_verify', req.body, data),
},
),
)
router.post(
'/douyin-mashida/certificates/cancel-verify',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
cancelAdminDouyinMashidaCertificateVerify(
req.body as AdminDouyinMashidaCertificateActionRouteBody,
),
{
successMessage: '券码已冲正',
errorMessage: '券码冲正失败',
scope: '[admin/douyin-mashida/certificates/cancel-verify]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_cancel_verify', req.body, data),
},
),
)
router.post(
'/douyin-mashida/certificates/sync',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
syncAdminDouyinMashidaCertificate(req.body as AdminDouyinMashidaCertificateActionRouteBody),
{
successMessage: '券码已重新同步',
errorMessage: '重新同步券码失败',
scope: '[admin/douyin-mashida/certificates/sync]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_sync', req.body, data),
},
),
)
router.post(
'/douyin-mashida/certificates/check',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
checkAdminDouyinMashidaCertificate(req.body as AdminDouyinMashidaCertificateActionRouteBody),
{
successMessage: '平台券码已查询',
errorMessage: '查询平台券码失败',
scope: '[admin/douyin-mashida/certificates/check]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_check', req.body, data),
},
),
)
router.post(
'/douyin-mashida/certificates/resend',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
resendAdminDouyinMashidaCertificate(req.body as AdminDouyinMashidaCertificateActionRouteBody),
{
successMessage: '券码已重发',
errorMessage: '重发券码失败',
scope: '[admin/douyin-mashida/certificates/resend]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_resend', req.body, data),
},
),
)
router.post(
'/douyin-mashida/certificates/invalidate',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
invalidateAdminDouyinMashidaCertificate(
req.body as AdminDouyinMashidaCertificateActionRouteBody,
),
{
successMessage: '券码已作废',
errorMessage: '作废券码失败',
scope: '[admin/douyin-mashida/certificates/invalidate]',
audit: (req, data) =>
buildCertificateAudit('douyin_mashida_certificate_invalidate', req.body, data),
},
),
)
router.post(
'/douyin-mashida/order-detail',
requireAdminRoles(MANAGE_ROLES),
createJsonHandler(
(req) =>
fetchAdminDouyinMashidaOrderDetail(req.body as AdminDouyinMashidaCertificateActionRouteBody),
{
successMessage: '平台订单详情已查询',
errorMessage: '查询平台订单详情失败',
scope: '[admin/douyin-mashida/order-detail]',
audit: (req, data) => buildCertificateAudit('douyin_mashida_order_detail', req.body, data),
},
),
)
/** 审计只记「操作了哪张券 + 结果摘要」,不整包落 rawPayload(里面有商品快照)。 */
function buildCertificateAudit(action: string, body: unknown, data: unknown) {
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
const result = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
const certificate = result.certificate as Record<string, unknown> | undefined
return {
action,
targetType: 'douyin_mashida_certificate',
targetId: resolveAuditTargetId(payload, certificate),
data: {
certNo: String(certificate?.certNo || payload.certNo || '').trim(),
orderId: String(
certificate?.orderId || payload.orderId || payload.platformOrderId || '',
).trim(),
reason: String(payload.reason || '').trim(),
success: result.success !== false,
},
}
}
function resolveAuditTargetId(
payload: Record<string, unknown>,
certificate: Record<string, unknown> | undefined,
): string {
return String(
certificate?.certNo || payload.certNo || payload.orderId || payload.platformOrderId || '-',
)
}
export default router
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
cancelAdminDouyinMashidaCertificateVerify,
pickAdminPlatformCouponRow,
resolvePlatformStatusLabel,
syncAdminDouyinMashidaCertificate,
verifyAdminDouyinMashidaCertificate,
} from './douyin-mashida-certificate-service.js'
/**
* 后台券操作的**守卫 + 对账口径**测试。
*
* 只覆盖不需要连库 / 连网关的分支:参数校验不全时会打到平台,对账选券错了会
* 拿错券码核销(真实踩坑:`10017 核销码与订单不匹配`)。出网行为由联调脚本覆盖。
*/
test('手动核销:券码为空直接拒绝(不打到平台)', async () => {
await assert.rejects(
() => verifyAdminDouyinMashidaCertificate({}),
(error: { errorCode?: string }) => {
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
return true
},
)
})
test('冲正:券码为空直接拒绝(不打到平台)', async () => {
await assert.rejects(
() => cancelAdminDouyinMashidaCertificateVerify({}),
(error: { errorCode?: string }) => {
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
return true
},
)
})
test('重新同步:券码为空直接拒绝(不打到平台)', async () => {
await assert.rejects(
() => syncAdminDouyinMashidaCertificate({}),
(error: { errorCode?: string }) => {
assert.equal(error.errorCode, 'admin_douyin_mashida_cert_no_missing')
return true
},
)
})
test('对账选券:平台沿用我方券码时精确命中', () => {
const picked = pickAdminPlatformCouponRow(
[
{ cert_no: 'DYCAAAA', status: 3, verify_num: 1 },
{ cert_no: 'DYCDDDD', status: 2, verify_num: 0 },
],
'DYCAAAA',
)
assert.equal(String(picked?.cert_no || ''), 'DYCAAAA')
})
test('对账选券:平台改过券码时不会错拿别人的券去核销', () => {
// 真实踩坑:syncV2 有 2/3 概率改写券码,此时必须挑「未使用」那张,
// 而不是列表第一条(第一条可能是别的订单已核销的券)。
const picked = pickAdminPlatformCouponRow(
[
{ cert_no: 'PLATFORM-USED', status: 3, verify_num: 2 },
{ cert_no: 'PLATFORM-UNUSED', status: 2, verify_num: 0 },
],
'DYCAAAA',
)
assert.equal(String(picked?.cert_no || ''), 'PLATFORM-UNUSED')
})
test('对账选券:平台没返回任何券时返回 null(不瞎猜)', () => {
assert.equal(pickAdminPlatformCouponRow([], 'DYCAAAA'), null)
assert.equal(pickAdminPlatformCouponRow([null, 'bad'] as never, 'DYCAAAA'), null)
})
test('平台状态文案:与 coupons.list 的 status 一一对应', () => {
assert.equal(resolvePlatformStatusLabel(1), '未激活')
assert.equal(resolvePlatformStatusLabel(2), '未使用')
assert.equal(resolvePlatformStatusLabel(3), '已使用')
assert.equal(resolvePlatformStatusLabel(4), '已废弃')
assert.equal(resolvePlatformStatusLabel(5), '已过期')
assert.equal(resolvePlatformStatusLabel(6), '已完成')
// 尚未回查到平台状态时不能显示成「未知 0」之类的噪音
assert.equal(resolvePlatformStatusLabel(Number.NaN), '')
})
@@ -0,0 +1,504 @@
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
import { findLatestOrderByPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
import {
findRedeemCertificateByCertNo,
listRedeemCertificatesForAdmin,
markRedeemCertificateInvalid,
markRedeemCertificatePlatformCert,
markRedeemCertificateSynced,
markRedeemCertificateVerifyCancelled,
resolveRedeemVerifyCertNo,
type RedeemCertificateAdminListQuery,
type RedeemCertificateAdminListRow,
type RedeemCertificateRow,
} from '../../repositories/redeem-certificate-repo.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import { createHttpError } from '../../utils/http.js'
import { logIntegration } from '../../utils/logger.js'
import {
fetchDouyinMashidaCoupons,
fetchDouyinMashidaOrderDetail,
syncDouyinMashidaCoupons,
} from '../platforms/douyin-mashida/gateway-client.js'
import {
DOUYIN_MASHIDA_PLATFORM,
DOUYIN_MASHIDA_PROVIDER,
} from '../platforms/douyin-mashida/order-mapper.js'
import {
assertDouyinMashidaConfig,
type DouyinMashidaSourceConfig,
} from '../platforms/douyin-mashida/source-config-service.js'
import {
cancelCertificateVerify,
ensureCertificateForOrderItem,
formatGrantTime,
verifyCertificate,
} from '../redeem/certificate-service.js'
import type {
AdminDouyinMashidaCertificateActionInput,
AdminDouyinMashidaCertificateListInput,
} from '../../types/admin/write-inputs.js'
/**
* 后台「抖音券核销」:给运营一块独立于快手电子凭证的界面。
*
* 为什么单独一套:表 `redeem_certificates` 是我们**自发**的兑换券(DYC 券码,走抖音码速达
* `syncV2` / `verifyV3` / `cancelVerify`),与快手行业电子凭证(`kuaishou_industry_vouchers`,
* 走快手 OpenAPI)是两张表、两套状态机,混用会污染状态。
*
* 主要补两个此前完全看不见的运营场景:
* - 皮肤单自动核销是 `void ... .catch(warn)`,失败只落日志,后台无感知;
* - 误核销时 `coupons.cancelVerify` 出网方法早已就绪,但没有入口。
*/
export async function listAdminDouyinMashidaCertificates(
input: AdminDouyinMashidaCertificateListInput = {},
) {
const query: RedeemCertificateAdminListQuery = {
certNo: String(input.certNo || '').trim(),
orderId: String(input.orderId || input.platformOrderId || '').trim(),
localOrderId: input.localOrderId,
orderItemId: input.orderItemId,
status: String(input.status || '').trim(),
syncStatus: String(input.syncStatus || '').trim(),
page: input.page,
pageSize: input.pageSize,
}
const result = await listRedeemCertificatesForAdmin(query)
return {
items: result.items.map(mapAdminCertificate),
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
},
}
}
/** 手动核销(幂等):本地已核销直接返回;未同步成功的券不允许核销(平台侧没有这张券)。 */
export async function verifyAdminDouyinMashidaCertificate(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const certificate = await resolveCertificate(input)
const config = assertDouyinMashidaConfig()
const verified = await verifyCertificate({ certNo: certificate.cert_no, config })
return {
success: true,
certificate: mapAdminCertificate(verified),
}
}
/**
* 冲正:先调平台 `coupons.cancelVerify`,成功后才把本地状态退回 `synced`。
*
* 顺序不能反:平台侧还是「已使用」时把本地改成可用,会造成「本地说没核销、平台说已核销」的状态漂移。
*/
export async function cancelAdminDouyinMashidaCertificateVerify(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const certificate = await resolveCertificate(input)
const config = assertDouyinMashidaConfig()
if (certificate.status !== 'verified') {
throw createHttpError('仅已核销的券码可以冲正', {
statusCode: 409,
errorCode: 'admin_douyin_mashida_certificate_not_verified',
context: { certNo: certificate.cert_no, status: certificate.status },
})
}
// 平台可能改过券码,冲正同样必须用平台侧券码
await cancelCertificateVerify({
certNo: resolveRedeemVerifyCertNo(certificate),
batchNo: String(input.batchNo || '').trim(),
config,
})
const reverted = (await markRedeemCertificateVerifyCancelled(certificate.id)) || certificate
logIntegration('[admin/douyin-mashida]', '券码已冲正', {
certNo: certificate.cert_no,
orderId: certificate.order_id,
})
return {
success: true,
certificate: mapAdminCertificate(reverted),
}
}
/** 重新同步:把券码(+当前 cert_link)再推一次 `coupons.syncV2`,用于同步失败后止血。 */
export async function syncAdminDouyinMashidaCertificate(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const certificate = await resolveCertificate(input)
const config = assertDouyinMashidaConfig()
if (certificate.status === 'verified') {
throw createHttpError('券码已核销,不能重复同步', {
statusCode: 409,
errorCode: 'admin_douyin_mashida_certificate_verified',
context: { certNo: certificate.cert_no },
})
}
if (certificate.status === 'invalid') {
throw createHttpError('券码已作废,不能重新同步', {
statusCode: 409,
errorCode: 'admin_douyin_mashida_certificate_invalid',
context: { certNo: certificate.cert_no },
})
}
const certLink = String(certificate.cert_link || config.certLink || '').trim()
if (!certLink) {
throw createHttpError('未配置兑换链接(certLink),无法同步券码', {
statusCode: 400,
errorCode: 'admin_douyin_mashida_cert_link_missing',
})
}
await syncDouyinMashidaCoupons({
orderId: certificate.order_id,
certs: [
{
certNo: certificate.cert_no,
certLink,
grantTime: formatGrantTime(certificate.grant_time || new Date().toISOString()),
},
],
config,
})
// syncV2 成功 ≠ 平台沿用我们的券码:回查平台侧真实券码再落地(与自动流程同一口径)
const rows = await fetchDouyinMashidaCoupons({ orderId: certificate.order_id, config })
const picked = pickAdminPlatformCouponRow(rows, certificate.cert_no)
const refreshed = await refreshPlatformSnapshot(certificate, picked)
return {
success: true,
certificate: mapAdminCertificate(refreshed || certificate),
platform: picked ? mapPlatformCoupon(picked) : null,
}
}
/** 对账:查平台侧 `coupons.list`,只读不改本地状态。 */
export async function checkAdminDouyinMashidaCertificate(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const certificate = await resolveCertificate(input)
const config = assertDouyinMashidaConfig()
const rows = await fetchDouyinMashidaCoupons({
orderId: certificate.order_id,
certNo: certificate.cert_no,
config,
})
const picked = pickAdminPlatformCouponRow(rows, certificate.cert_no)
return {
success: true,
certificate: mapAdminCertificate(certificate),
platform: picked ? mapPlatformCoupon(picked) : null,
platformRowCount: rows.length,
}
}
/** 重发券码:按订单号再走一遍发券+同步(券已存在时幂等复用)。 */
export async function resendAdminDouyinMashidaCertificate(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const config = assertDouyinMashidaConfig()
const context = await resolveCertificateOrderContext(input)
const certificate = await ensureCertificateForOrderItem({
order: context.order,
orderItem: context.orderItem,
config,
})
return {
success: true,
certificate: mapAdminCertificate(certificate),
order: {
id: context.order.id,
platformOrderId: String(context.order.platform_order_id || '').trim(),
},
}
}
/** 手动作废:把未核销的券打入 `invalid`(已核销的必须先冲正,避免资损)。 */
export async function invalidateAdminDouyinMashidaCertificate(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const certificate = await resolveCertificate(input)
if (certificate.status === 'verified') {
throw createHttpError('券码已核销,请先冲正再作废', {
statusCode: 409,
errorCode: 'admin_douyin_mashida_certificate_verified',
context: { certNo: certificate.cert_no },
})
}
const reason = String(input.reason || '').trim() || '后台手动作废'
const updated = await markRedeemCertificateInvalid({ id: certificate.id, reason })
return {
success: true,
certificate: mapAdminCertificate(updated || certificate),
}
}
/** 平台订单详情(只读快照,便于运营核对平台侧发生了什么)。 */
export async function fetchAdminDouyinMashidaOrderDetail(
input: AdminDouyinMashidaCertificateActionInput = {},
) {
const config = assertDouyinMashidaConfig()
const shopOrderId = String(input.orderId || input.platformOrderId || '').trim()
if (!shopOrderId) {
throw createHttpError('缺少平台订单号', {
statusCode: 400,
errorCode: 'admin_douyin_mashida_order_id_missing',
})
}
const detail = await fetchDouyinMashidaOrderDetail({ shopOrderId, config })
return {
success: true,
orderId: shopOrderId,
detail,
}
}
/** 后台出参:后台是运营侧,券码不掩码(买家侧兑换页才掩码)。 */
function mapAdminCertificate(row: RedeemCertificateRow | RedeemCertificateAdminListRow) {
const listRow = row as RedeemCertificateAdminListRow
const platformStatus = Number(row.platform_status ?? Number.NaN)
return {
id: row.id,
provider: row.provider,
certNo: row.cert_no,
/** 核销实际会用的券码(平台侧优先) */
verifyCertNo: resolveRedeemVerifyCertNo(row),
platformCertNo: String(row.platform_cert_no || '').trim(),
platformCertId: String(row.platform_cert_id || '').trim(),
platformStatus: Number.isFinite(platformStatus) ? platformStatus : null,
platformStatusLabel: resolvePlatformStatusLabel(platformStatus),
platformSyncedAt: row.platform_synced_at,
orderId: row.order_id,
parentOrderId: row.parent_order_id,
localOrderId: row.local_order_id,
orderItemId: row.order_item_id,
certLink: row.cert_link,
status: row.status,
syncStatus: row.sync_status,
syncError: String(row.sync_error || '').trim(),
grantTime: row.grant_time,
syncedAt: row.synced_at,
verifiedAt: row.verified_at,
/** 仅列表 join 有值;单券详情接口为空串 */
taskNo: String(listRow.task_no || '').trim(),
skuCode: String(listRow.sku_code || '').trim(),
skuName: String(listRow.sku_name || '').trim(),
rawPayload: parseJsonObject(row.raw_payload_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function mapPlatformCoupon(row: Record<string, unknown>) {
const status = Number(row.status ?? Number.NaN)
return {
certNo: String(row.cert_no || '').trim(),
certId: String(row.cert_id || '').trim(),
status: Number.isFinite(status) ? status : null,
statusLabel: resolvePlatformStatusLabel(status),
verifyNum: Number(row.verify_num || 0) || 0,
certLink: String(row.cert_link || '').trim(),
verified: Number(row.status) === 3 || Number(row.verify_num || 0) > 0,
}
}
/** 平台侧券状态:1未激活/2未使用/3已使用/4已废弃/5已过期/6已完成。 */
export function resolvePlatformStatusLabel(status: number): string {
if (!Number.isFinite(status)) {
return ''
}
switch (Math.trunc(status)) {
case 1:
return '未激活'
case 2:
return '未使用'
case 3:
return '已使用'
case 4:
return '已废弃'
case 5:
return '已过期'
case 6:
return '已完成'
default:
return `未知(${Math.trunc(status)})`
}
}
async function refreshPlatformSnapshot(
certificate: RedeemCertificateRow,
picked: Record<string, unknown> | null,
): Promise<RedeemCertificateRow | null> {
if (!picked) {
return null
}
const synced = await markRedeemCertificateSynced({
id: certificate.id,
certLink: String(certificate.cert_link || '').trim(),
})
return (
(await markRedeemCertificatePlatformCert({
id: certificate.id,
platformCertNo: String(picked.cert_no || '').trim(),
platformCertId: String(picked.cert_id || '').trim(),
platformStatus: Number.isFinite(Number(picked.status)) ? Number(picked.status) : null,
})) || synced
)
}
/** 从 `coupons.list` 结果里挑出属于本次的那张券(与自动流程保持同一口径)。 */
export function pickAdminPlatformCouponRow(
rows: Array<Record<string, unknown>>,
ourCertNo: string,
): Record<string, unknown> | null {
const usable = (Array.isArray(rows) ? rows : []).filter(
(row) => Object.prototype.toString.call(row) === '[object Object]',
)
if (usable.length === 0) {
return null
}
const exact = usable.find(
(row) => String(row.cert_no || '').trim() === String(ourCertNo || '').trim(),
)
if (exact) {
return exact
}
if (usable.length === 1) {
return usable[0] || null
}
return (
usable.find((row) => Number(row.status) === 2) ||
usable.find((row) => !(Number(row.status) === 3 || Number(row.verify_num || 0) > 0)) ||
usable[0] ||
null
)
}
async function resolveCertificate(
input: AdminDouyinMashidaCertificateActionInput,
): Promise<RedeemCertificateRow> {
const certNo = String(input.certNo || '').trim()
if (!certNo) {
throw createHttpError('请填写券码', {
statusCode: 400,
errorCode: 'admin_douyin_mashida_cert_no_missing',
})
}
// 运营可能贴的是平台侧券码(平台会改写我们的券码),两边都要认
const certificate =
(await findRedeemCertificateByCertNo(certNo)) || (await findCertificateByPlatformCertNo(certNo))
if (!certificate) {
throw createHttpError('券码不存在', {
statusCode: 404,
errorCode: 'admin_douyin_mashida_certificate_not_found',
context: { certNo },
})
}
return certificate
}
async function findCertificateByPlatformCertNo(
certNo: string,
): Promise<RedeemCertificateRow | null> {
const rows = await listRedeemCertificatesForAdmin({ certNo, pageSize: 1 })
return (
rows.items.find(
(row) => String(row.platform_cert_no || '').trim() === String(certNo || '').trim(),
) || null
)
}
/** 按订单号反查本地抖音订单 + 订单项(重发券码需要完整上下文)。 */
async function resolveCertificateOrderContext(
input: AdminDouyinMashidaCertificateActionInput,
): Promise<{ order: OrderRow; orderItem: OrderItemRow }> {
const orderId = String(input.orderId || input.platformOrderId || '').trim()
const localOrderId = Number(input.localOrderId || 0)
const order =
Number.isFinite(localOrderId) && localOrderId > 0
? await getOrderById(Math.trunc(localOrderId))
: orderId
? await findLatestOrderByPlatformOrderId({
provider: DOUYIN_MASHIDA_PROVIDER,
platform: DOUYIN_MASHIDA_PLATFORM,
platformOrderId: orderId,
})
: null
if (!order) {
throw createHttpError('未找到对应的抖音订单,请填写平台订单号或本地订单 ID', {
statusCode: 404,
errorCode: 'admin_douyin_mashida_order_not_found',
context: { orderId, localOrderId },
})
}
const items = await listOrderItemsByOrderId(order.id)
const requestedItemId = Number(input.orderItemId || 0)
const orderItem =
(Number.isFinite(requestedItemId) && requestedItemId > 0
? items.find((item: OrderItemRow) => item.id === Math.trunc(requestedItemId))
: items[0]) || null
if (!orderItem) {
throw createHttpError('该订单没有订单项,无法发券', {
statusCode: 404,
errorCode: 'admin_douyin_mashida_order_item_not_found',
context: { orderId: order.id },
})
}
return { order, orderItem }
}
function parseJsonObject(value: unknown): JsonObject {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as JsonObject
}
try {
const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as JsonObject)
: {}
} catch {
return {}
}
}
export type AdminDouyinMashidaCertificate = ReturnType<typeof mapAdminCertificate>
export type AdminDouyinMashidaPlatformCoupon = ReturnType<typeof mapPlatformCoupon>
@@ -30,6 +30,8 @@ import type {
AdminScheduledJobsConfigInput,
AdminTaskKuaishouIndustryConsumeInput,
AdminTaskManualDispatchInput,
AdminDouyinMashidaCertificateListInput,
AdminDouyinMashidaCertificateActionInput,
} from './write-inputs.js'
export type AdminOrderRouteQuery = AdminOrderListQueryInput
@@ -66,6 +68,8 @@ export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtu
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
export type AdminTaskKuaishouIndustryConsumeRouteBody = AdminTaskKuaishouIndustryConsumeInput
export type AdminDouyinMashidaCertificateListRouteQuery = AdminDouyinMashidaCertificateListInput
export type AdminDouyinMashidaCertificateActionRouteBody = AdminDouyinMashidaCertificateActionInput
export type AdminOrderRouteParams = { orderId?: string }
export type AdminTaskRouteParams = { taskId?: string; bindingId?: string }
@@ -229,6 +229,32 @@ export type AdminCloudtentaclesFullFlowInput = {
vnKey?: string
}
/** 后台「抖音码速达兑换券」列表筛选。 */
export type AdminDouyinMashidaCertificateListInput = {
certNo?: string
orderId?: string
platformOrderId?: string
localOrderId?: number | string
orderItemId?: number | string
status?: string
syncStatus?: string
page?: number | string
pageSize?: number | string
}
/** 后台「抖音码速达兑换券」单券操作:按券码定位,订单字段仅「重发券码」用。 */
export type AdminDouyinMashidaCertificateActionInput = {
certNo?: string
orderId?: string
platformOrderId?: string
localOrderId?: number | string
orderItemId?: number | string
/** cancelVerify 的批次号,平台需要时才填 */
batchNo?: string
/** 手动作废原因 */
reason?: string
}
export type AdminTaskManualDispatchInput = {
outcome?: string
resultMessage?: string