From 5a73fb74a9bd133635202d7af9c4e8eb12e7ad2c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 12 Oct 2026 19:45:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(douyin-mashida):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8A=96=E9=9F=B3=E5=88=B8=E6=A0=B8=E9=94=80=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=88=E5=88=97=E8=A1=A8/=E6=A0=B8?= =?UTF-8?q?=E9=94=80/=E5=86=B2=E6=AD=A3/=E5=90=8C=E6=AD=A5/=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6/=E4=BD=9C=E5=BA=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抖音订单并非纯自动发货:内部 SKU 命中 AFFILIATE_DASH_SKU_MAPPING 才走自动通道, 未命中显式回落人工,且核销依赖买家自己到 /redeem 输订单号。此前核销侧在后台完全 不可见:自动核销是 void .catch(warn) 只落日志,cancelVerify 出网方法已就绪但没有入口。 照快手电子凭证的骨架做一套抖音版(两张表两套状态机,不复用): - 券码状态、平台侧状态、同步错误全部可视化,支持只看同步失败 - 核销/冲正/重新同步/平台对账/重发券码/作废,每个写操作都带审计 - 券码列显示「核销实际会用的券码」(platform_cert_no 优先),平台改过名时额外标注 - 冲正先打平台 cancelVerify 再回退本地,避免本地与平台状态漂移 --- .../repositories/redeem-certificate-repo.ts | 176 +++++ apps/backend/src/routes/admin.ts | 2 + .../src/routes/admin/douyin-mashida.ts | 188 +++++ ...douyin-mashida-certificate-service.test.ts | 88 +++ .../douyin-mashida-certificate-service.ts | 504 +++++++++++++ apps/backend/src/types/admin/route-inputs.ts | 4 + apps/backend/src/types/admin/write-inputs.ts | 26 + apps/frontend/src/App.tsx | 7 + apps/frontend/src/layouts/AdminLayout.tsx | 5 + .../AdminDouyinMashidaCertificatePage.tsx | 681 ++++++++++++++++++ .../admin/douyin-mashida-certificate.ts | 72 ++ apps/frontend/src/services/admin/index.ts | 1 + .../types/admin/douyin-mashida-certificate.ts | 126 ++++ apps/frontend/src/types/admin/index.ts | 16 + 14 files changed, 1896 insertions(+) create mode 100644 apps/backend/src/routes/admin/douyin-mashida.ts create mode 100644 apps/backend/src/services/admin/douyin-mashida-certificate-service.test.ts create mode 100644 apps/backend/src/services/admin/douyin-mashida-certificate-service.ts create mode 100644 apps/frontend/src/pages/admin/AdminDouyinMashidaCertificatePage.tsx create mode 100644 apps/frontend/src/services/admin/douyin-mashida-certificate.ts create mode 100644 apps/frontend/src/types/admin/douyin-mashida-certificate.ts diff --git a/apps/backend/src/repositories/redeem-certificate-repo.ts b/apps/backend/src/repositories/redeem-certificate-repo.ts index 11a79f23..855fb8f3 100644 --- a/apps/backend/src/repositories/redeem-certificate-repo.ts +++ b/apps/backend/src/repositories/redeem-certificate-repo.ts @@ -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( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 31a0866a..6114005f 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -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) diff --git a/apps/backend/src/routes/admin/douyin-mashida.ts b/apps/backend/src/routes/admin/douyin-mashida.ts new file mode 100644 index 00000000..1bf5c8cb --- /dev/null +++ b/apps/backend/src/routes/admin/douyin-mashida.ts @@ -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) : {} + const result = data && typeof data === 'object' ? (data as Record) : {} + const certificate = result.certificate as Record | 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, + certificate: Record | undefined, +): string { + return String( + certificate?.certNo || payload.certNo || payload.orderId || payload.platformOrderId || '-', + ) +} + +export default router diff --git a/apps/backend/src/services/admin/douyin-mashida-certificate-service.test.ts b/apps/backend/src/services/admin/douyin-mashida-certificate-service.test.ts new file mode 100644 index 00000000..a6276a5e --- /dev/null +++ b/apps/backend/src/services/admin/douyin-mashida-certificate-service.test.ts @@ -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), '') +}) diff --git a/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts new file mode 100644 index 00000000..941f47e1 --- /dev/null +++ b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts @@ -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) { + 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 | null, +): Promise { + 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>, + ourCertNo: string, +): Record | 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 { + 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 { + 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 +export type AdminDouyinMashidaPlatformCoupon = ReturnType diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index 548c3f41..f952cf12 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -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 } diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index 9a789f9a..bc1b268b 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -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 diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 459f4006..e1012d31 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -23,6 +23,9 @@ const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage') const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage')) +const AdminDouyinMashidaCertificatePage = lazy( + () => import('@/pages/admin/AdminDouyinMashidaCertificatePage'), +) const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage')) @@ -141,6 +144,10 @@ function AdminApplication() { } /> }> } /> + } + /> } /> }> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 04f887b0..04803b50 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -139,6 +139,11 @@ export default function AdminLayout() { icon: , label: '电子凭证', }, + { + key: '/admin/douyin-mashida-certificates', + icon: , + label: '抖音券核销', + }, ] const items: MenuProps['items'] = [ diff --git a/apps/frontend/src/pages/admin/AdminDouyinMashidaCertificatePage.tsx b/apps/frontend/src/pages/admin/AdminDouyinMashidaCertificatePage.tsx new file mode 100644 index 00000000..0f747c22 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminDouyinMashidaCertificatePage.tsx @@ -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([]) + const [total, setTotal] = useState(0) + const [selected, setSelected] = useState(null) + const [filters, setFilters] = useState(createEmptyFilters) + // 异步操作后刷新列表时,沿用用户最后一次的筛选条件 + const filtersRef = useRef(filters) + const [result, setResult] = useState(null) + const [manualCertNo, setManualCertNo] = useState('') + + useEffect(() => { + void loadCertificates() + }, []) + + async function loadCertificates(patch: Partial = {}) { + 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 { + const next = { ...filtersRef.current, ...patch } + filtersRef.current = next + setFilters(next) + return next + } + + const columns = useMemo>( + () => [ + { + title: '券码', + width: 240, + render: (_, row) => ( +
+ + {row.verifyCertNo || '-'} + + {/* 平台改过券码时必须让它显眼:贴错券码核销会得到 10017 */} + {row.platformCertNo && row.platformCertNo !== row.certNo ? ( + 平台已改名:{row.certNo} + ) : ( + 发券:{formatAdminDateTime(row.grantTime)} + )} +
+ ), + }, + { + title: '订单商品', + width: 300, + ellipsis: true, + render: (_, row) => ( +
+ + {row.orderId || '-'} + + {row.skuCode || row.skuName ? ( + + {row.skuCode || row.skuName} + + ) : ( + 未关联商品 + )} + + 任务 {row.taskNo || '-'} + {row.parentOrderId && row.parentOrderId !== row.orderId + ? ` · 父单 ${row.parentOrderId}` + : ''} + +
+ ), + }, + { + title: '本地状态', + width: 150, + render: (_, row) => ( +
+ {resolveStatusLabel(row.status)} + 同步 {resolveSyncLabel(row.syncStatus)} + {row.syncError ? ( + + {row.syncError} + + ) : null} +
+ ), + }, + { + title: '平台侧', + width: 160, + render: (_, row) => ( +
+ + {row.platformStatusLabel || '未回查'} + + 核销:{formatAdminDateTime(row.verifiedAt)} +
+ ), + }, + { + title: '操作', + fixed: 'right', + width: 96, + render: (_, row) => { + const isSelected = selected?.id === row.id + return ( + + ) + }, + }, + ], + [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 ( +
+ + + + + updateFilters({ certNo: event.target.value })} + /> + updateFilters({ orderId: event.target.value })} + /> + updateFilters({ syncStatus: value || '' })} + /> + + + + + + +
+ + + 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 }) + }, + })} + /> + + + +
+
当前卡券
+ {selected ? ( +
+
+ + {selected.verifyCertNo || '-'} + + + + {resolveStatusLabel(selected.status)} + + + +
+
+
+ 订单号 + + {selected.orderId || '-'} + +
+
+ 本地订单 + {selected.localOrderId ?? '-'} +
+
+ 平台侧 + {selected.platformStatusLabel || '未回查'} + {selected.platformCertNo || selected.platformCertId || '-'} +
+
+ 兑换链接 + + {selected.certLink || '-'} + +
+
+
+ ) : ( +
+ 尚未选择卡券 + 请从左侧列表选择,或在下方手动指定。 +
+ )} +
+ +
+
手动指定卡券
+
+ +
+
+ +
+ + + + + + + void runAction('invalidate', '作废券码', '券码已作废', () => + invalidateAdminDouyinMashidaCertificate({ + ...buildPayload(), + reason: '后台手动作废', + }), + ) + } + > + + +
+ + {selected?.status === 'verified' ? ( + + ) : null} +
+ + + + {result ? ( +
+ + {result.title} + {formatAdminDateTime(result.executedAt)} + + + {result.message} + + {result.key === 'check' || result.key === 'sync' ? ( + + {renderPlatformCouponDescriptions(result.raw)} + + ) : null} +
+ ) : ( + 暂无操作记录 + )} +
+ + ) +} + +/** 从「对账 / 重新同步」的返回里取出平台侧券信息。 */ +function renderPlatformCouponDescriptions(raw: unknown) { + const coupon = extractPlatformCoupon(raw) + + return [ + + {coupon?.certNo || '-'} + , + + {coupon?.statusLabel || '-'} + , + + {coupon ? String(coupon.verifyNum) : '-'} + , + + {coupon?.certLink || '-'} + , + ] +} + +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' + } +} diff --git a/apps/frontend/src/services/admin/douyin-mashida-certificate.ts b/apps/frontend/src/services/admin/douyin-mashida-certificate.ts new file mode 100644 index 00000000..0073edb3 --- /dev/null +++ b/apps/frontend/src/services/admin/douyin-mashida-certificate.ts @@ -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( + `${BASE}/certificates`, + params as Record, + ) +} + +export function verifyAdminDouyinMashidaCertificate( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost(`${BASE}/certificates/verify`, payload) +} + +export function cancelAdminDouyinMashidaCertificateVerify( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost( + `${BASE}/certificates/cancel-verify`, + payload, + ) +} + +export function syncAdminDouyinMashidaCertificate( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost(`${BASE}/certificates/sync`, payload) +} + +export function checkAdminDouyinMashidaCertificate( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost(`${BASE}/certificates/check`, payload) +} + +export function resendAdminDouyinMashidaCertificate( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost(`${BASE}/certificates/resend`, payload) +} + +export function invalidateAdminDouyinMashidaCertificate( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost( + `${BASE}/certificates/invalidate`, + payload, + ) +} + +export function fetchAdminDouyinMashidaOrderDetail( + payload: AdminDouyinMashidaCertificateActionPayload, +) { + return apiPost(`${BASE}/order-detail`, payload) +} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 0b5714d9..8c130b0c 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -5,6 +5,7 @@ export * from './users' export * from './audit-logs' export * from './platform-config' export * from './kuaishou-industry' +export * from './douyin-mashida-certificate' export * from './orders' export * from './tasks' export * from './dev-mock' diff --git a/apps/frontend/src/types/admin/douyin-mashida-certificate.ts b/apps/frontend/src/types/admin/douyin-mashida-certificate.ts new file mode 100644 index 00000000..eaed6fce --- /dev/null +++ b/apps/frontend/src/types/admin/douyin-mashida-certificate.ts @@ -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 + 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 +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 15ebab34..91be2bab 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -52,6 +52,22 @@ export type { AdminKuaishouIndustryVoucherToolPayload, } 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 export type { AdminNotificationBarkRecipient,