feat(douyin-mashida): 新增抖音券核销后台界面(列表/核销/冲正/同步/对账/作废)
抖音订单并非纯自动发货:内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING 才走自动通道, 未命中显式回落人工,且核销依赖买家自己到 /redeem 输订单号。此前核销侧在后台完全 不可见:自动核销是 void .catch(warn) 只落日志,cancelVerify 出网方法已就绪但没有入口。 照快手电子凭证的骨架做一套抖音版(两张表两套状态机,不复用): - 券码状态、平台侧状态、同步错误全部可视化,支持只看同步失败 - 核销/冲正/重新同步/平台对账/重发券码/作废,每个写操作都带审计 - 券码列显示「核销实际会用的券码」(platform_cert_no 优先),平台改过名时额外标注 - 冲正先打平台 cancelVerify 再回退本地,避免本地与平台状态漂移
This commit is contained in:
@@ -61,6 +61,137 @@ export function generateRedeemCertificateCode(
|
|||||||
return `${REDEEM_CERT_CODE_PREFIX}${encodeCrockfordBase32(randomBytes(CERT_CODE_RANDOM_BYTES))}`
|
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: {
|
export async function findRedeemCertificateByOrder(input: {
|
||||||
provider: string
|
provider: string
|
||||||
orderId: string
|
orderId: string
|
||||||
@@ -262,6 +393,51 @@ export async function invalidateRedeemCertificatesByLocalOrder(input: {
|
|||||||
return result.rows
|
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 {
|
function encodeCrockfordBase32(bytes: Uint8Array): string {
|
||||||
let bits = 0
|
let bits = 0
|
||||||
let value = 0
|
let value = 0
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import authRouter from './admin/auth.js'
|
|||||||
import auditLogsRouter from './admin/audit-logs.js'
|
import auditLogsRouter from './admin/audit-logs.js'
|
||||||
import dashboardRouter from './admin/dashboard.js'
|
import dashboardRouter from './admin/dashboard.js'
|
||||||
import devMockRouter from './admin/dev-mock.js'
|
import devMockRouter from './admin/dev-mock.js'
|
||||||
|
import douyinMashidaRouter from './admin/douyin-mashida.js'
|
||||||
import financeRouter from './admin/finance.js'
|
import financeRouter from './admin/finance.js'
|
||||||
import filesRouter from './admin/files.js'
|
import filesRouter from './admin/files.js'
|
||||||
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
||||||
@@ -30,6 +31,7 @@ router.use(rbacRouter)
|
|||||||
router.use(auditLogsRouter)
|
router.use(auditLogsRouter)
|
||||||
router.use(platformConfigRouter)
|
router.use(platformConfigRouter)
|
||||||
router.use(kuaishouIndustryRouter)
|
router.use(kuaishouIndustryRouter)
|
||||||
|
router.use(douyinMashidaRouter)
|
||||||
router.use(ordersRouter)
|
router.use(ordersRouter)
|
||||||
router.use(notificationsRouter)
|
router.use(notificationsRouter)
|
||||||
router.use(realtimeRouter)
|
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,
|
AdminScheduledJobsConfigInput,
|
||||||
AdminTaskKuaishouIndustryConsumeInput,
|
AdminTaskKuaishouIndustryConsumeInput,
|
||||||
AdminTaskManualDispatchInput,
|
AdminTaskManualDispatchInput,
|
||||||
|
AdminDouyinMashidaCertificateListInput,
|
||||||
|
AdminDouyinMashidaCertificateActionInput,
|
||||||
} from './write-inputs.js'
|
} from './write-inputs.js'
|
||||||
|
|
||||||
export type AdminOrderRouteQuery = AdminOrderListQueryInput
|
export type AdminOrderRouteQuery = AdminOrderListQueryInput
|
||||||
@@ -66,6 +68,8 @@ export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtu
|
|||||||
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
|
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
|
||||||
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
|
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
|
||||||
export type AdminTaskKuaishouIndustryConsumeRouteBody = AdminTaskKuaishouIndustryConsumeInput
|
export type AdminTaskKuaishouIndustryConsumeRouteBody = AdminTaskKuaishouIndustryConsumeInput
|
||||||
|
export type AdminDouyinMashidaCertificateListRouteQuery = AdminDouyinMashidaCertificateListInput
|
||||||
|
export type AdminDouyinMashidaCertificateActionRouteBody = AdminDouyinMashidaCertificateActionInput
|
||||||
|
|
||||||
export type AdminOrderRouteParams = { orderId?: string }
|
export type AdminOrderRouteParams = { orderId?: string }
|
||||||
export type AdminTaskRouteParams = { taskId?: string; bindingId?: string }
|
export type AdminTaskRouteParams = { taskId?: string; bindingId?: string }
|
||||||
|
|||||||
@@ -229,6 +229,32 @@ export type AdminCloudtentaclesFullFlowInput = {
|
|||||||
vnKey?: string
|
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 = {
|
export type AdminTaskManualDispatchInput = {
|
||||||
outcome?: string
|
outcome?: string
|
||||||
resultMessage?: string
|
resultMessage?: string
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')
|
|||||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||||
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
||||||
|
const AdminDouyinMashidaCertificatePage = lazy(
|
||||||
|
() => import('@/pages/admin/AdminDouyinMashidaCertificatePage'),
|
||||||
|
)
|
||||||
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
|
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
|
||||||
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
|
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
|
||||||
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
|
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
|
||||||
@@ -141,6 +144,10 @@ function AdminApplication() {
|
|||||||
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
|
||||||
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
|
||||||
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
|
||||||
|
<Route
|
||||||
|
path="douyin-mashida-certificates"
|
||||||
|
element={<AdminDouyinMashidaCertificatePage />}
|
||||||
|
/>
|
||||||
<Route path="dev-mock" element={<AdminDevMockPage />} />
|
<Route path="dev-mock" element={<AdminDevMockPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireRole roles={['admin']} />}>
|
<Route element={<RequireRole roles={['admin']} />}>
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ export default function AdminLayout() {
|
|||||||
icon: <SafetyCertificateOutlined />,
|
icon: <SafetyCertificateOutlined />,
|
||||||
label: '电子凭证',
|
label: '电子凭证',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/douyin-mashida-certificates',
|
||||||
|
icon: <SafetyCertificateOutlined />,
|
||||||
|
label: '抖音券核销',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import type { TableColumnsType } from 'antd'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
|
import { showError, showSuccess } from '@/lib/feedback'
|
||||||
|
import {
|
||||||
|
cancelAdminDouyinMashidaCertificateVerify,
|
||||||
|
checkAdminDouyinMashidaCertificate,
|
||||||
|
fetchAdminDouyinMashidaCertificates,
|
||||||
|
invalidateAdminDouyinMashidaCertificate,
|
||||||
|
resendAdminDouyinMashidaCertificate,
|
||||||
|
syncAdminDouyinMashidaCertificate,
|
||||||
|
verifyAdminDouyinMashidaCertificate,
|
||||||
|
} from '@/services/admin'
|
||||||
|
import type {
|
||||||
|
AdminDouyinMashidaCertificate,
|
||||||
|
AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
AdminDouyinMashidaPlatformCoupon,
|
||||||
|
} from '@/types/admin'
|
||||||
|
import { getAdminRole } from '@/utils/admin-auth'
|
||||||
|
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「抖音券核销」。
|
||||||
|
*
|
||||||
|
* 与「快手电子凭证」是两套独立的表和状态机:
|
||||||
|
* 这里的券是我们自己生成的 DYC 券码,经 `coupons.syncV2` 交给抖音码速达展示给买家,
|
||||||
|
* 核销打的是 `coupons.verifyV3`、冲正打 `coupons.cancelVerify`。
|
||||||
|
*
|
||||||
|
* 界面照 `AdminKuaishouIndustryPage` 的骨架做(筛选 + 左列表 + 右操作),运营不用重新学习;
|
||||||
|
* 但数据口径换成抖音自己的:**平台侧券码优先**(syncV2 可能改写我们的券码,
|
||||||
|
* 拿旧券码核销会得到 `10017 核销码与订单不匹配`)。
|
||||||
|
*/
|
||||||
|
type CertificateFilterState = {
|
||||||
|
certNo: string
|
||||||
|
orderId: string
|
||||||
|
status: string
|
||||||
|
syncStatus: string
|
||||||
|
/** 只看需要人跟进的异常:同步失败 / 已核销但平台显示未使用 */
|
||||||
|
abnormalOnly: boolean
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionKey = 'check' | 'verify' | 'cancel' | 'sync' | 'resend' | 'invalidate'
|
||||||
|
|
||||||
|
type ActionResultView = {
|
||||||
|
key: ActionKey
|
||||||
|
title: string
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
executedAt: string
|
||||||
|
raw: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ label: '已生成 issued', value: 'issued' },
|
||||||
|
{ label: '已同步 synced', value: 'synced' },
|
||||||
|
{ label: '已核销 verified', value: 'verified' },
|
||||||
|
{ label: '已作废 invalid', value: 'invalid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SYNC_STATUS_OPTIONS = [
|
||||||
|
{ label: '待同步 pending', value: 'pending' },
|
||||||
|
{ label: '同步成功 success', value: 'success' },
|
||||||
|
{ label: '同步失败 failed', value: 'failed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function createEmptyFilters(): CertificateFilterState {
|
||||||
|
return {
|
||||||
|
certNo: '',
|
||||||
|
orderId: '',
|
||||||
|
status: '',
|
||||||
|
syncStatus: '',
|
||||||
|
abnormalOnly: false,
|
||||||
|
page: 1,
|
||||||
|
pageSize: ADMIN_DEFAULT_PAGE_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDouyinMashidaCertificatePage() {
|
||||||
|
const role = getAdminRole()
|
||||||
|
const canInvalidate = role === 'admin' || role === 'operator'
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [actionLoading, setActionLoading] = useState('')
|
||||||
|
const [certificates, setCertificates] = useState<AdminDouyinMashidaCertificate[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [selected, setSelected] = useState<AdminDouyinMashidaCertificate | null>(null)
|
||||||
|
const [filters, setFilters] = useState<CertificateFilterState>(createEmptyFilters)
|
||||||
|
// 异步操作后刷新列表时,沿用用户最后一次的筛选条件
|
||||||
|
const filtersRef = useRef(filters)
|
||||||
|
const [result, setResult] = useState<ActionResultView | null>(null)
|
||||||
|
const [manualCertNo, setManualCertNo] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCertificates()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadCertificates(patch: Partial<CertificateFilterState> = {}) {
|
||||||
|
const next = updateFilters(patch)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminDouyinMashidaCertificates({
|
||||||
|
certNo: next.certNo.trim(),
|
||||||
|
orderId: next.orderId.trim(),
|
||||||
|
status: next.abnormalOnly ? '' : next.status,
|
||||||
|
syncStatus: next.abnormalOnly ? 'failed' : next.syncStatus,
|
||||||
|
page: next.page,
|
||||||
|
pageSize: next.pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCertificates(response.data.items)
|
||||||
|
setTotal(response.data.pagination.total)
|
||||||
|
setSelected((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return response.data.items.find((item) => item.id === current.id) || current
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '查询抖音兑换券失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilters(patch: Partial<CertificateFilterState>): CertificateFilterState {
|
||||||
|
const next = { ...filtersRef.current, ...patch }
|
||||||
|
filtersRef.current = next
|
||||||
|
setFilters(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = useMemo<TableColumnsType<AdminDouyinMashidaCertificate>>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: '券码',
|
||||||
|
width: 240,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
copyable={Boolean(row.verifyCertNo)}
|
||||||
|
title={row.verifyCertNo || undefined}
|
||||||
|
>
|
||||||
|
{row.verifyCertNo || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
{/* 平台改过券码时必须让它显眼:贴错券码核销会得到 10017 */}
|
||||||
|
{row.platformCertNo && row.platformCertNo !== row.certNo ? (
|
||||||
|
<span className="muted">平台已改名:{row.certNo}</span>
|
||||||
|
) : (
|
||||||
|
<span className="muted">发券:{formatAdminDateTime(row.grantTime)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订单商品',
|
||||||
|
width: 300,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Typography.Text copyable={Boolean(row.orderId)} ellipsis={{ tooltip: row.orderId }}>
|
||||||
|
{row.orderId || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
{row.skuCode || row.skuName ? (
|
||||||
|
<Typography.Text
|
||||||
|
ellipsis={{
|
||||||
|
tooltip: row.skuName ? `${row.skuName}(${row.skuCode})` : row.skuCode,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.skuCode || row.skuName}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<span className="muted">未关联商品</span>
|
||||||
|
)}
|
||||||
|
<span className="muted">
|
||||||
|
任务 {row.taskNo || '-'}
|
||||||
|
{row.parentOrderId && row.parentOrderId !== row.orderId
|
||||||
|
? ` · 父单 ${row.parentOrderId}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本地状态',
|
||||||
|
width: 150,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag color={resolveStatusColor(row.status)}>{resolveStatusLabel(row.status)}</Tag>
|
||||||
|
<span className="muted">同步 {resolveSyncLabel(row.syncStatus)}</span>
|
||||||
|
{row.syncError ? (
|
||||||
|
<Typography.Text
|
||||||
|
type="danger"
|
||||||
|
ellipsis={{ tooltip: row.syncError }}
|
||||||
|
className="muted"
|
||||||
|
>
|
||||||
|
{row.syncError}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平台侧',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<Tag color={resolvePlatformColor(row.platformStatus)}>
|
||||||
|
{row.platformStatusLabel || '未回查'}
|
||||||
|
</Tag>
|
||||||
|
<span className="muted">核销:{formatAdminDateTime(row.verifiedAt)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 96,
|
||||||
|
render: (_, row) => {
|
||||||
|
const isSelected = selected?.id === row.id
|
||||||
|
return (
|
||||||
|
<Button size="small" type="primary" ghost={!isSelected} onClick={() => selectRow(row)}>
|
||||||
|
{isSelected ? '已选' : '选择'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[selected?.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectRow(row: AdminDouyinMashidaCertificate) {
|
||||||
|
setSelected(row)
|
||||||
|
setManualCertNo('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCertNo = String(selected?.verifyCertNo || selected?.certNo || manualCertNo).trim()
|
||||||
|
|
||||||
|
function buildPayload(): AdminDouyinMashidaCertificateActionPayload {
|
||||||
|
return {
|
||||||
|
certNo: targetCertNo,
|
||||||
|
orderId: selected?.orderId || undefined,
|
||||||
|
localOrderId: selected?.localOrderId || undefined,
|
||||||
|
orderItemId: selected?.orderItemId || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(
|
||||||
|
key: ActionKey,
|
||||||
|
title: string,
|
||||||
|
successMessage: string,
|
||||||
|
action: () => Promise<{ data: unknown }>,
|
||||||
|
) {
|
||||||
|
if (!targetCertNo) {
|
||||||
|
showError('请先选择券码,或在下方手动填写券码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(key)
|
||||||
|
try {
|
||||||
|
const response = await action()
|
||||||
|
publishResult(key, title, true, successMessage, response.data)
|
||||||
|
showSuccess(successMessage)
|
||||||
|
await loadCertificates()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : `${title}失败`
|
||||||
|
publishResult(key, title, false, message, null)
|
||||||
|
showError(message)
|
||||||
|
} finally {
|
||||||
|
setActionLoading('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishResult(
|
||||||
|
key: ActionKey,
|
||||||
|
title: string,
|
||||||
|
success: boolean,
|
||||||
|
message: string,
|
||||||
|
raw: unknown,
|
||||||
|
) {
|
||||||
|
setResult({
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
executedAt: new Date().toISOString(),
|
||||||
|
raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-stack douyin-mashida-certificate-page">
|
||||||
|
<PageHeader
|
||||||
|
title="抖音券核销"
|
||||||
|
description="抖音码速达兑换券:查询本地券码、手动核销 / 冲正 / 重发,并与平台侧对账"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Space wrap className="filter-form">
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="券码(我方或平台侧)"
|
||||||
|
value={filters.certNo}
|
||||||
|
onChange={(event) => updateFilters({ certNo: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="平台订单号"
|
||||||
|
value={filters.orderId}
|
||||||
|
onChange={(event) => updateFilters({ orderId: event.target.value })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="券状态"
|
||||||
|
style={{ width: 152 }}
|
||||||
|
value={filters.status || undefined}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
disabled={filters.abnormalOnly}
|
||||||
|
onChange={(value) => updateFilters({ status: value || '' })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="同步状态"
|
||||||
|
style={{ width: 152 }}
|
||||||
|
value={filters.syncStatus || undefined}
|
||||||
|
options={SYNC_STATUS_OPTIONS}
|
||||||
|
disabled={filters.abnormalOnly}
|
||||||
|
onChange={(value) => updateFilters({ syncStatus: value || '' })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
type={filters.abnormalOnly ? 'primary' : 'default'}
|
||||||
|
onClick={() =>
|
||||||
|
void loadCertificates({ ...filters, abnormalOnly: !filters.abnormalOnly, page: 1 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filters.abnormalOnly ? '只看同步失败(点击取消)' : '只看同步失败'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => void loadCertificates({ page: 1 })}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void loadCertificates(createEmptyFilters())}>清空筛选</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="kuaishou-industry-split">
|
||||||
|
<Card title="本地券码" className="kuaishou-industry-voucher-card">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="middle"
|
||||||
|
tableLayout="fixed"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={certificates}
|
||||||
|
loading={loading}
|
||||||
|
rowClassName={(row) =>
|
||||||
|
selected?.id === row.id ? 'kuaishou-industry-row-selected' : ''
|
||||||
|
}
|
||||||
|
scroll={{ x: 1046 }}
|
||||||
|
pagination={buildAdminTablePagination({
|
||||||
|
current: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
total,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
void loadCertificates({ page, pageSize })
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="券码操作" className="kuaishou-industry-tool-card">
|
||||||
|
<div className="kuaishou-industry-tool-section">
|
||||||
|
<div className="kuaishou-industry-tool-section-title">当前卡券</div>
|
||||||
|
{selected ? (
|
||||||
|
<div className="kuaishou-industry-selected-voucher">
|
||||||
|
<div className="kuaishou-industry-selected-header">
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
copyable={{ text: selected.verifyCertNo }}
|
||||||
|
className="kuaishou-industry-selected-code"
|
||||||
|
>
|
||||||
|
{selected.verifyCertNo || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space size={4}>
|
||||||
|
<Tag color={resolveStatusColor(selected.status)}>
|
||||||
|
{resolveStatusLabel(selected.status)}
|
||||||
|
</Tag>
|
||||||
|
<Button type="link" size="small" onClick={() => setSelected(null)}>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="kuaishou-industry-selected-meta">
|
||||||
|
<div>
|
||||||
|
<span>订单号</span>
|
||||||
|
<Typography.Text copyable={{ text: selected.orderId }}>
|
||||||
|
{selected.orderId || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>本地订单</span>
|
||||||
|
<strong>{selected.localOrderId ?? '-'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>平台侧</span>
|
||||||
|
<strong>{selected.platformStatusLabel || '未回查'}</strong>
|
||||||
|
<small>{selected.platformCertNo || selected.platformCertId || '-'}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>兑换链接</span>
|
||||||
|
<strong title={selected.certLink || undefined}>
|
||||||
|
{selected.certLink || '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kuaishou-industry-selected-empty">
|
||||||
|
<strong>尚未选择卡券</strong>
|
||||||
|
<span>请从左侧列表选择,或在下方手动指定。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kuaishou-industry-tool-section">
|
||||||
|
<div className="kuaishou-industry-tool-section-title">手动指定卡券</div>
|
||||||
|
<div className="kuaishou-industry-tool-grid">
|
||||||
|
<label className="kuaishou-industry-tool-field kuaishou-industry-tool-field-wide">
|
||||||
|
<span>券码</span>
|
||||||
|
<Input
|
||||||
|
disabled={Boolean(selected)}
|
||||||
|
placeholder="我方 DYC… 或平台侧券码"
|
||||||
|
value={manualCertNo}
|
||||||
|
onChange={(event) => setManualCertNo(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kuaishou-industry-actions">
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
loading={actionLoading === 'check'}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction('check', '平台对账', '平台券码已查询', () =>
|
||||||
|
checkAdminDouyinMashidaCertificate(buildPayload()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
平台对账
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
loading={actionLoading === 'resend'}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction('resend', '重发券码', '券码已重发', () =>
|
||||||
|
resendAdminDouyinMashidaCertificate(buildPayload()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
重发券码
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
loading={actionLoading === 'sync'}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction('sync', '重新同步', '券码已重新同步', () =>
|
||||||
|
syncAdminDouyinMashidaCertificate(buildPayload()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
重新同步
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
loading={actionLoading === 'verify'}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction('verify', '手动核销', '券码已核销', () =>
|
||||||
|
verifyAdminDouyinMashidaCertificate(buildPayload()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
手动核销
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
loading={actionLoading === 'cancel'}
|
||||||
|
onClick={() =>
|
||||||
|
void runAction('cancel', '冲正', '券码已冲正', () =>
|
||||||
|
cancelAdminDouyinMashidaCertificateVerify(buildPayload()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
冲正
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认作废该券码?"
|
||||||
|
description="已核销的券码不能作废,请先冲正。"
|
||||||
|
okText="作废"
|
||||||
|
cancelText="取消"
|
||||||
|
disabled={!canInvalidate}
|
||||||
|
onConfirm={() =>
|
||||||
|
void runAction('invalidate', '作废券码', '券码已作废', () =>
|
||||||
|
invalidateAdminDouyinMashidaCertificate({
|
||||||
|
...buildPayload(),
|
||||||
|
reason: '后台手动作废',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button danger type="primary" icon={<DeleteOutlined />} disabled={!canInvalidate}>
|
||||||
|
作废
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected?.status === 'verified' ? (
|
||||||
|
<Alert
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="该券已核销:退款请走售后流程,误核销请先冲正再作废。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="操作结果">
|
||||||
|
{result ? (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={result.success ? 'green' : 'red'}>{result.title}</Tag>
|
||||||
|
<span className="muted">{formatAdminDateTime(result.executedAt)}</span>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type={result.success ? undefined : 'danger'}>
|
||||||
|
{result.message}
|
||||||
|
</Typography.Text>
|
||||||
|
{result.key === 'check' || result.key === 'sync' ? (
|
||||||
|
<Descriptions size="small" column={2} bordered>
|
||||||
|
{renderPlatformCouponDescriptions(result.raw)}
|
||||||
|
</Descriptions>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="muted">暂无操作记录</span>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从「对账 / 重新同步」的返回里取出平台侧券信息。 */
|
||||||
|
function renderPlatformCouponDescriptions(raw: unknown) {
|
||||||
|
const coupon = extractPlatformCoupon(raw)
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Descriptions.Item key="certNo" label="平台券码">
|
||||||
|
{coupon?.certNo || '-'}
|
||||||
|
</Descriptions.Item>,
|
||||||
|
<Descriptions.Item key="status" label="平台状态">
|
||||||
|
{coupon?.statusLabel || '-'}
|
||||||
|
</Descriptions.Item>,
|
||||||
|
<Descriptions.Item key="verifyNum" label="核销次数">
|
||||||
|
{coupon ? String(coupon.verifyNum) : '-'}
|
||||||
|
</Descriptions.Item>,
|
||||||
|
<Descriptions.Item key="certLink" label="平台链接">
|
||||||
|
{coupon?.certLink || '-'}
|
||||||
|
</Descriptions.Item>,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPlatformCoupon(raw: unknown): AdminDouyinMashidaPlatformCoupon | null {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = raw as { data?: unknown }
|
||||||
|
const source = (data.data && typeof data.data === 'object' ? data.data : raw) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
const platform = source.platform
|
||||||
|
|
||||||
|
return platform && typeof platform === 'object'
|
||||||
|
? (platform as AdminDouyinMashidaPlatformCoupon)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatusColor(status: string): string {
|
||||||
|
switch (String(status || '').trim()) {
|
||||||
|
case 'verified':
|
||||||
|
return 'green'
|
||||||
|
case 'synced':
|
||||||
|
return 'blue'
|
||||||
|
case 'invalid':
|
||||||
|
return 'red'
|
||||||
|
case 'issued':
|
||||||
|
return 'gold'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatusLabel(status: string): string {
|
||||||
|
switch (String(status || '').trim()) {
|
||||||
|
case 'verified':
|
||||||
|
return '已核销'
|
||||||
|
case 'synced':
|
||||||
|
return '已同步'
|
||||||
|
case 'invalid':
|
||||||
|
return '已作废'
|
||||||
|
case 'issued':
|
||||||
|
return '已生成'
|
||||||
|
default:
|
||||||
|
return status || '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSyncLabel(syncStatus: string): string {
|
||||||
|
switch (String(syncStatus || '').trim()) {
|
||||||
|
case 'success':
|
||||||
|
return '成功'
|
||||||
|
case 'failed':
|
||||||
|
return '失败'
|
||||||
|
case 'pending':
|
||||||
|
return '待同步'
|
||||||
|
default:
|
||||||
|
return syncStatus || '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台侧状态:3 已使用是真核销,其余都按待处理/异常提示。 */
|
||||||
|
function resolvePlatformColor(platformStatus: number | null): string {
|
||||||
|
switch (Number(platformStatus)) {
|
||||||
|
case 2:
|
||||||
|
return 'blue'
|
||||||
|
case 3:
|
||||||
|
case 6:
|
||||||
|
return 'green'
|
||||||
|
case 4:
|
||||||
|
case 5:
|
||||||
|
return 'red'
|
||||||
|
case 1:
|
||||||
|
return 'gold'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { apiGet, apiPost } from '@/lib/http'
|
||||||
|
import type {
|
||||||
|
AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
AdminDouyinMashidaCertificateCheckResult,
|
||||||
|
AdminDouyinMashidaCertificateInvalidateResult,
|
||||||
|
AdminDouyinMashidaCertificateListPayload,
|
||||||
|
AdminDouyinMashidaCertificateListResult,
|
||||||
|
AdminDouyinMashidaCertificateResendResult,
|
||||||
|
AdminDouyinMashidaCertificateSyncResult,
|
||||||
|
AdminDouyinMashidaCertificateVerifyResult,
|
||||||
|
AdminDouyinMashidaOrderDetailResult,
|
||||||
|
} from '@/types/admin'
|
||||||
|
|
||||||
|
/** 后台「抖音券核销」接口。与快手电子凭证(kuaishou-industry)是两套独立的表与状态机。 */
|
||||||
|
const BASE = '/api/v1/admin/douyin-mashida'
|
||||||
|
|
||||||
|
export function fetchAdminDouyinMashidaCertificates(
|
||||||
|
params: AdminDouyinMashidaCertificateListPayload = {},
|
||||||
|
) {
|
||||||
|
return apiGet<AdminDouyinMashidaCertificateListResult>(
|
||||||
|
`${BASE}/certificates`,
|
||||||
|
params as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminDouyinMashidaCertificate(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateVerifyResult>(`${BASE}/certificates/verify`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelAdminDouyinMashidaCertificateVerify(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateVerifyResult>(
|
||||||
|
`${BASE}/certificates/cancel-verify`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAdminDouyinMashidaCertificate(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateSyncResult>(`${BASE}/certificates/sync`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAdminDouyinMashidaCertificate(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateCheckResult>(`${BASE}/certificates/check`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resendAdminDouyinMashidaCertificate(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateResendResult>(`${BASE}/certificates/resend`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAdminDouyinMashidaCertificate(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaCertificateInvalidateResult>(
|
||||||
|
`${BASE}/certificates/invalidate`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminDouyinMashidaOrderDetail(
|
||||||
|
payload: AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
) {
|
||||||
|
return apiPost<AdminDouyinMashidaOrderDetailResult>(`${BASE}/order-detail`, payload)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export * from './users'
|
|||||||
export * from './audit-logs'
|
export * from './audit-logs'
|
||||||
export * from './platform-config'
|
export * from './platform-config'
|
||||||
export * from './kuaishou-industry'
|
export * from './kuaishou-industry'
|
||||||
|
export * from './douyin-mashida-certificate'
|
||||||
export * from './orders'
|
export * from './orders'
|
||||||
export * from './tasks'
|
export * from './tasks'
|
||||||
export * from './dev-mock'
|
export * from './dev-mock'
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { AdminPagination } from './common'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「抖音码速达兑换券」视图模型。
|
||||||
|
*
|
||||||
|
* 与后端 `services/admin/douyin-mashida-certificate-service.ts` 的出参一一对应。
|
||||||
|
* 券码**不掩码**:这是后台运营界面,不是买家侧兑换页(`/redeem` 那边才掩码)。
|
||||||
|
*/
|
||||||
|
export interface AdminDouyinMashidaCertificate {
|
||||||
|
id: number
|
||||||
|
provider: string
|
||||||
|
/** 我方生成的券码(DYC 前缀) */
|
||||||
|
certNo: string
|
||||||
|
/** 核销实际会用的券码(平台侧优先,平台可能改写过) */
|
||||||
|
verifyCertNo: string
|
||||||
|
/** 平台侧实际券码 */
|
||||||
|
platformCertNo: string
|
||||||
|
platformCertId: string
|
||||||
|
/** 平台侧券状态:1未激活/2未使用/3已使用/4已废弃/5已过期/6已完成 */
|
||||||
|
platformStatus: number | null
|
||||||
|
platformStatusLabel: string
|
||||||
|
platformSyncedAt: string | null
|
||||||
|
/** syncV2 / order.message 用的订单号(子单优先、父单兜底) */
|
||||||
|
orderId: string
|
||||||
|
parentOrderId: string
|
||||||
|
localOrderId: number | null
|
||||||
|
orderItemId: number | null
|
||||||
|
/** 交给平台的兑换/领取链接 */
|
||||||
|
certLink: string
|
||||||
|
/** issued 已生成 / synced 已同步 / verified 已核销 / invalid 已作废 */
|
||||||
|
status: string
|
||||||
|
/** pending / success / failed */
|
||||||
|
syncStatus: string
|
||||||
|
syncError: string
|
||||||
|
grantTime: string | null
|
||||||
|
syncedAt: string | null
|
||||||
|
verifiedAt: string | null
|
||||||
|
/** 仅列表 join 有值 */
|
||||||
|
taskNo: string
|
||||||
|
skuCode: string
|
||||||
|
skuName: string
|
||||||
|
rawPayload: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台侧 `coupons.list` 返回的那张券(对账用)。 */
|
||||||
|
export interface AdminDouyinMashidaPlatformCoupon {
|
||||||
|
certNo: string
|
||||||
|
certId: string
|
||||||
|
status: number | null
|
||||||
|
statusLabel: string
|
||||||
|
verifyNum: number
|
||||||
|
certLink: string
|
||||||
|
verified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateListResult {
|
||||||
|
items: AdminDouyinMashidaCertificate[]
|
||||||
|
pagination: AdminPagination
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateListPayload {
|
||||||
|
certNo?: string
|
||||||
|
orderId?: string
|
||||||
|
platformOrderId?: string
|
||||||
|
localOrderId?: number | string
|
||||||
|
orderItemId?: number | string
|
||||||
|
status?: string
|
||||||
|
syncStatus?: string
|
||||||
|
page?: number | string
|
||||||
|
pageSize?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateActionPayload {
|
||||||
|
certNo?: string
|
||||||
|
orderId?: string
|
||||||
|
platformOrderId?: string
|
||||||
|
localOrderId?: number | string
|
||||||
|
orderItemId?: number | string
|
||||||
|
batchNo?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateVerifyResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateCancelResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateSyncResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
platform: AdminDouyinMashidaPlatformCoupon | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateCheckResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
platform: AdminDouyinMashidaPlatformCoupon | null
|
||||||
|
platformRowCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateResendResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
order: {
|
||||||
|
id: number | null
|
||||||
|
platformOrderId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaCertificateInvalidateResult {
|
||||||
|
success: boolean
|
||||||
|
certificate: AdminDouyinMashidaCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDouyinMashidaOrderDetailResult {
|
||||||
|
success: boolean
|
||||||
|
orderId: string
|
||||||
|
detail: Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -52,6 +52,22 @@ export type {
|
|||||||
AdminKuaishouIndustryVoucherToolPayload,
|
AdminKuaishouIndustryVoucherToolPayload,
|
||||||
} from './kuaishou-industry'
|
} from './kuaishou-industry'
|
||||||
|
|
||||||
|
// Douyin Mashida redeem certificates (抖音码速达兑换券)
|
||||||
|
export type {
|
||||||
|
AdminDouyinMashidaCertificate,
|
||||||
|
AdminDouyinMashidaPlatformCoupon,
|
||||||
|
AdminDouyinMashidaCertificateListResult,
|
||||||
|
AdminDouyinMashidaCertificateListPayload,
|
||||||
|
AdminDouyinMashidaCertificateActionPayload,
|
||||||
|
AdminDouyinMashidaCertificateVerifyResult,
|
||||||
|
AdminDouyinMashidaCertificateCancelResult,
|
||||||
|
AdminDouyinMashidaCertificateSyncResult,
|
||||||
|
AdminDouyinMashidaCertificateCheckResult,
|
||||||
|
AdminDouyinMashidaCertificateResendResult,
|
||||||
|
AdminDouyinMashidaCertificateInvalidateResult,
|
||||||
|
AdminDouyinMashidaOrderDetailResult,
|
||||||
|
} from './douyin-mashida-certificate'
|
||||||
|
|
||||||
// Platform config types
|
// Platform config types
|
||||||
export type {
|
export type {
|
||||||
AdminNotificationBarkRecipient,
|
AdminNotificationBarkRecipient,
|
||||||
|
|||||||
Reference in New Issue
Block a user