新增快手电子凭证后台工具

This commit is contained in:
yml2213
2026-07-09 18:39:34 +08:00
parent 1bfb32dcd0
commit c61078d3f6
29 changed files with 3034 additions and 474 deletions
@@ -13,6 +13,16 @@ type VoucherPatchColumn = {
cast?: string
}
export type KuaishouIndustryVoucherAdminListQuery = {
oid?: string
voucherCode?: string
taskId?: number | string
sellerId?: string
status?: string
page?: number | string
pageSize?: number | string
}
const VOUCHER_CODE_PREFIX = 'KSV'
const VOUCHER_CODE_RANDOM_BYTES = 10
const VOUCHER_CODE_MAX_GENERATE_ATTEMPTS = 5
@@ -217,6 +227,75 @@ export async function listKuaishouIndustryVouchersByTaskId(
return result.rows
}
export async function listKuaishouIndustryVouchersForAdmin(
input: KuaishouIndustryVoucherAdminListQuery = {},
): Promise<{ items: KuaishouIndustryVoucherRow[]; total: number; page: number; pageSize: number }> {
const page = normalizePositiveLimit(input.page, 1)
const pageSize = Math.min(normalizePositiveLimit(input.pageSize, 50), 200)
const where: string[] = []
const params: unknown[] = []
const oid = String(input.oid || '').trim()
if (oid) {
params.push(oid)
where.push(`oid = $${params.length}`)
}
const voucherCode = String(input.voucherCode || '').trim()
if (voucherCode) {
params.push(Array.from(new Set([voucherCode, voucherCode.toUpperCase()])))
where.push(`voucher_code = ANY($${params.length}::text[])`)
}
const taskId = Number(input.taskId || 0)
if (Number.isFinite(taskId) && taskId > 0) {
params.push(Math.trunc(taskId))
where.push(`task_id = $${params.length}`)
}
const sellerId = String(input.sellerId || '').trim()
if (sellerId) {
params.push(sellerId)
where.push(`seller_id = $${params.length}`)
}
const status = String(input.status || '').trim().toUpperCase()
if (status) {
params.push(status)
where.push(`status = $${params.length}`)
}
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
const totalResult = await query<{ total: string | number }>(
`
SELECT COUNT(*) AS total
FROM kuaishou_industry_vouchers
${whereSql}
`,
params,
)
const listParams = [...params, pageSize, (page - 1) * pageSize]
const listResult = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
${whereSql}
ORDER BY updated_at DESC, 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 findKuaishouIndustryVoucherByCode(
voucherCode: string,
oid = '',
+2
View File
@@ -4,6 +4,7 @@ import authRouter from "./admin/auth.js";
import auditLogsRouter from "./admin/audit-logs.js";
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
import dashboardRouter from "./admin/dashboard.js";
import kuaishouIndustryRouter from "./admin/kuaishou-industry.js";
import ordersRouter from "./admin/orders.js";
import platformConfigRouter from "./admin/platform-config.js";
import { requireAdminSession } from "./admin/session.js";
@@ -19,6 +20,7 @@ router.use(dashboardRouter);
router.use(usersRouter);
router.use(auditLogsRouter);
router.use(platformConfigRouter);
router.use(kuaishouIndustryRouter);
router.use(ordersRouter);
router.use(tasksRouter);
router.use(cloudtentaclesRecordsRouter);
@@ -0,0 +1,179 @@
import { Router } from 'express'
import {
approveAdminKuaishouIndustryRefund,
checkAdminKuaishouIndustryVoucherAvailable,
consumeAdminKuaishouIndustryVoucherByCode,
disagreeAdminKuaishouIndustryRefund,
listAdminKuaishouIndustryRefunds,
listAdminKuaishouIndustryVouchers,
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '../../services/admin/kuaishou-industry-admin-service.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
import type {
AdminKuaishouIndustryRefundApproveRouteBody,
AdminKuaishouIndustryRefundDisagreeRouteBody,
AdminKuaishouIndustryRefundListRouteBody,
AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
AdminKuaishouIndustryVoucherConsumeRouteBody,
AdminKuaishouIndustryVoucherResendRouteBody,
AdminKuaishouIndustryVoucherReverseRouteBody,
} from '../../types/admin/route-inputs.js'
import type { KuaishouIndustryVoucherAdminListQuery } from '../../repositories/kuaishou-industry-voucher-repo.js'
const router = Router()
router.get(
'/kuaishou-industry/vouchers',
createJsonHandler(
(req) => listAdminKuaishouIndustryVouchers(req.query as KuaishouIndustryVoucherAdminListQuery),
{
successMessage: 'ok',
errorMessage: '查询快手行业电子凭证失败',
scope: '[admin/kuaishou-industry/vouchers]',
},
),
)
router.post(
'/kuaishou-industry/refunds/list',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) => listAdminKuaishouIndustryRefunds(req.body as AdminKuaishouIndustryRefundListRouteBody),
{
successMessage: '售后单列表已查询',
errorMessage: '查询快手售后单列表失败',
scope: '[admin/kuaishou-industry/refunds/list]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_refund_list', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/refunds/approve',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
approveAdminKuaishouIndustryRefund(req.body as AdminKuaishouIndustryRefundApproveRouteBody),
{
successMessage: '同意退款接口已执行',
errorMessage: '执行同意退款失败',
scope: '[admin/kuaishou-industry/refunds/approve]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_refund_approve', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/refunds/disagree',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
disagreeAdminKuaishouIndustryRefund(req.body as AdminKuaishouIndustryRefundDisagreeRouteBody),
{
successMessage: '不同意退款接口已执行',
errorMessage: '执行不同意退款失败',
scope: '[admin/kuaishou-industry/refunds/disagree]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_refund_disagree', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/check-available',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
checkAdminKuaishouIndustryVoucherAvailable(
req.body as AdminKuaishouIndustryVoucherCheckAvailableRouteBody,
),
{
successMessage: '电子凭证有效性已检查',
errorMessage: '检查电子凭证有效性失败',
scope: '[admin/kuaishou-industry/vouchers/check-available]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_voucher_check_available', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/reverse',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
reverseAdminKuaishouIndustryVoucher(req.body as AdminKuaishouIndustryVoucherReverseRouteBody),
{
successMessage: '电子凭证冲正回调已执行',
errorMessage: '执行电子凭证冲正失败',
scope: '[admin/kuaishou-industry/vouchers/reverse]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_voucher_reverse', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/consume',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
consumeAdminKuaishouIndustryVoucherByCode(
req.body as AdminKuaishouIndustryVoucherConsumeRouteBody,
),
{
successMessage: '电子凭证已手动核销',
errorMessage: '手动核销电子凭证失败',
scope: '[admin/kuaishou-industry/vouchers/consume]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_voucher_consume', req.body, data),
},
),
)
router.post(
'/kuaishou-industry/vouchers/resend-code',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
resendAdminKuaishouIndustryVoucherCode(
req.body as AdminKuaishouIndustryVoucherResendRouteBody,
),
{
successMessage: '电子凭证发码回调已重发',
errorMessage: '重发电子凭证发码回调失败',
scope: '[admin/kuaishou-industry/vouchers/resend-code]',
audit: (req, data) => buildKuaishouIndustryAudit('kuaishou_industry_voucher_resend_code', req.body, data),
},
),
)
function buildKuaishouIndustryAudit(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> : {}
return {
action,
targetType: 'kuaishou_industry',
targetId: resolveAuditTargetId(payload),
data: {
sellerId: String(payload.sellerId || ''),
oid: String(payload.oid || payload.orderId || ''),
voucherCode: String(payload.voucherCode || ''),
refundId: String(payload.refundId || ''),
success: Boolean(result.success),
httpStatus: Number(result.httpStatus || 0) || 0,
error: String(result.error || ''),
},
}
}
function resolveAuditTargetId(payload: Record<string, unknown>): string {
return String(
payload.voucherCode ||
payload.refundId ||
payload.oid ||
payload.orderId ||
payload.sellerId ||
'kuaishou-industry',
)
}
export default router
@@ -0,0 +1,379 @@
import {
findKuaishouIndustryVoucherByCode,
listKuaishouIndustryVouchersByOid,
listKuaishouIndustryVouchersByTaskId,
listKuaishouIndustryVouchersForAdmin,
updateKuaishouIndustryVoucherByCode,
type KuaishouIndustryVoucherAdminListQuery,
} from '../../repositories/kuaishou-industry-voucher-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { getTaskById } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { maskSecret } from '../../utils/masking.js'
import { checkKuaishouIndustryEticketAvailable } from '../platforms/kuaishou-industry/check-available-service.js'
import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou-industry/send-code-service.js'
import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js'
import {
approveKuaishouIndustryRefund,
disagreeKuaishouIndustryRefund,
listKuaishouIndustryRefunds,
} from '../platforms/kuaishou-industry/refund-service.js'
import { reverseKuaishouIndustryCallback } from '../platforms/kuaishou-industry/reverse-callback-service.js'
import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
type JsonObject = Record<string, any>
export async function listAdminKuaishouIndustryVouchers(
input: KuaishouIndustryVoucherAdminListQuery = {},
) {
const result = await listKuaishouIndustryVouchersForAdmin(input)
return {
items: result.items.map(mapAdminKuaishouIndustryVoucher),
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
},
}
}
export async function listAdminKuaishouIndustryRefunds(input: JsonObject = {}) {
assertRequired(input.beginTime, '开始时间未填写')
assertRequired(input.endTime, '结束时间未填写')
return mapOpenApiResult(await listKuaishouIndustryRefunds(input))
}
export async function approveAdminKuaishouIndustryRefund(input: JsonObject = {}) {
assertRequired(input.refundId, '退款单编号未填写')
assertRequired(input.refundAmount, '退款金额未填写')
return mapOpenApiResult(await approveKuaishouIndustryRefund(input))
}
export async function disagreeAdminKuaishouIndustryRefund(input: JsonObject = {}) {
assertRequired(input.refundId, '退款单编号未填写')
assertRequired(input.sellerDisagreeReason, '拒绝原因未填写')
assertRequired(input.sellerDisagreeDesc, '拒绝说明未填写')
assertRequired(input.status, '退款单当前状态未填写')
assertRequired(input.negotiateStatus, '协商状态未填写')
return mapOpenApiResult(await disagreeKuaishouIndustryRefund(input))
}
export async function checkAdminKuaishouIndustryVoucherAvailable(input: JsonObject = {}) {
const voucher = await resolveOptionalVoucher(input)
const payload = buildVoucherOpenApiPayload(input, voucher)
assertRequired(payload.sellerId, '卖家编号未填写')
assertRequired(payload.eticketType || payload.bizTypeCode, '电子凭证类型未填写')
assertEtickets(payload.etickets, '电子凭证列表未填写')
return mapOpenApiResult(await checkKuaishouIndustryEticketAvailable(payload))
}
export async function reverseAdminKuaishouIndustryVoucher(input: JsonObject = {}) {
const voucher = await resolveOptionalVoucher(input)
const payload = buildVoucherOpenApiPayload(input, voucher)
assertRequired(payload.oid, '订单号未填写')
assertEtickets(payload.etickets, '冲正券码列表未填写')
const result = await reverseKuaishouIndustryCallback(payload)
let updatedVoucher: KuaishouIndustryVoucherRow | null = null
if (result.success && voucher) {
updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
status: 'UNUSED',
consumeSerialNum: '',
consumeDetailsJson: [],
consumedAt: null,
destroyedAt: null,
updatedAt: new Date().toISOString(),
})
}
return {
...mapOpenApiResult(result),
voucher: updatedVoucher ? mapAdminKuaishouIndustryVoucher(updatedVoucher) : null,
}
}
export async function consumeAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
source: 'admin_tool_manual_consume',
token: String(input.token || voucher.token || '').trim(),
consumeType: String(input.consumeType || 'delivery').trim() || 'delivery',
consumeTime: Date.now(),
...(input.serialNum ? { serialNum: String(input.serialNum).trim() } : {}),
...(input.eticketType ? { eticketType: String(input.eticketType).trim() } : {}),
...(input.storeName ? { storeName: String(input.storeName).trim() } : {}),
...(input.storeAddress ? { storeAddress: String(input.storeAddress).trim() } : {}),
...(input.expressCode ? { expressCode: String(input.expressCode).trim() } : {}),
...(input.expressNo ? { expressNo: String(input.expressNo).trim() } : {}),
}
if (task) {
consumeInput.task = task
}
const result = await consumeKuaishouIndustryVoucher(voucher, consumeInput)
if (!result.ok || !result.voucher) {
throw createHttpError(result.errorMessage || '电子凭证核销失败', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_consume_failed',
})
}
return {
success: true,
voucher: mapAdminKuaishouIndustryVoucher(result.voucher),
task: task ? mapTaskReference(task) : null,
}
}
export async function resendAdminKuaishouIndustryVoucherCode(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const order = voucher.order_id ? await getOrderById(voucher.order_id) : null
const result = await resendKuaishouIndustryVoucherSendCallback({
voucherCode: voucher.voucher_code,
oid: voucher.oid,
preferredTotalGoodsValue: Number(order?.total_amount || 0) || 0,
})
return {
success: result.success,
response: 'response' in result ? result.response || null : null,
error: result.error || '',
voucher: result.voucher ? mapAdminKuaishouIndustryVoucher(result.voucher) : null,
}
}
function buildVoucherOpenApiPayload(
input: JsonObject,
voucher: KuaishouIndustryVoucherRow | null,
): JsonObject {
const etickets = normalizeAdminEtickets(input.etickets)
const voucherCode = String(voucher?.voucher_code || input.voucherCode || '').trim()
return {
...input,
sellerId: String(input.sellerId || voucher?.seller_id || '').trim(),
oid: String(input.oid || input.orderId || voucher?.oid || '').trim(),
orderId: String(input.orderId || input.oid || voucher?.oid || '').trim(),
token: String(input.token || voucher?.token || '').trim(),
serialNum: String(input.serialNum || voucher?.consume_serial_num || '').trim(),
etickets: etickets.length > 0
? etickets
: voucherCode
? [{ id: voucherCode, code: voucherCode, num: 1 }]
: [],
}
}
async function resolveOptionalVoucher(input: JsonObject): Promise<KuaishouIndustryVoucherRow | null> {
const voucherCode = String(input.voucherCode || '').trim()
if (!voucherCode) {
return null
}
const voucher = await findKuaishouIndustryVoucherByCode(voucherCode, String(input.oid || input.orderId || '').trim())
if (!voucher) {
throw createHttpError('电子凭证不存在', {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_voucher_not_found',
})
}
return voucher
}
async function resolveRequiredVoucher(input: JsonObject): Promise<KuaishouIndustryVoucherRow> {
const voucher = await resolveOptionalVoucher(input)
if (voucher) {
return voucher
}
const taskId = Number(input.taskId || 0)
if (Number.isFinite(taskId) && taskId > 0) {
const vouchers = await listKuaishouIndustryVouchersByTaskId(Math.trunc(taskId))
return resolveSingleVoucher(vouchers, '当前任务没有关联电子凭证', '当前任务关联多个电子凭证,请指定券码')
}
const oid = String(input.oid || input.orderId || '').trim()
if (oid) {
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
return resolveSingleVoucher(vouchers, '当前订单没有关联电子凭证', '当前订单关联多个电子凭证,请指定券码')
}
throw createHttpError('请填写券码、任务 ID 或订单号', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_voucher_selector_missing',
})
}
function resolveSingleVoucher(
vouchers: KuaishouIndustryVoucherRow[],
missingMessage: string,
multipleMessage: string,
): KuaishouIndustryVoucherRow {
if (vouchers.length === 0) {
throw createHttpError(missingMessage, {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_voucher_not_found',
})
}
if (vouchers.length > 1) {
throw createHttpError(multipleMessage, {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_voucher_ambiguous',
})
}
return vouchers[0] as KuaishouIndustryVoucherRow
}
function mapOpenApiResult(result: KuaishouIndustryOpenApiCallResult) {
return {
success: result.success,
response: result.response || null,
error: result.error || '',
request: sanitizeOpenApiRequest(result.request),
durationMs: result.durationMs || 0,
httpStatus: result.httpStatus || 0,
skippedReason: result.skippedReason || '',
}
}
function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null {
if (!request) {
return null
}
return {
...request,
access_token: maskSecret(request.access_token),
sign: maskSecret(request.sign),
}
}
function mapAdminKuaishouIndustryVoucher(voucher: KuaishouIndustryVoucherRow) {
return {
id: voucher.id,
voucherCode: voucher.voucher_code,
oid: voucher.oid,
orderId: voucher.order_id,
taskId: voucher.task_id,
unitIndex: voucher.unit_index,
sellerId: voucher.seller_id,
tokenMasked: maskSecret(voucher.token),
status: voucher.status,
validStartTime: Number(voucher.valid_start_time || 0) || 0,
validEndTime: Number(voucher.valid_end_time || 0) || 0,
consumeSerialNum: voucher.consume_serial_num,
consumeDetails: parseJsonArray(voucher.consume_details_json),
consumedAt: voucher.consumed_at,
destroyedAt: voucher.destroyed_at,
sendCallbackStatus: voucher.send_callback_status,
sendCallbackAttemptCount: voucher.send_callback_attempt_count,
sendCallbackLastError: voucher.send_callback_last_error,
sendCallbackSentAt: voucher.send_callback_sent_at,
rawPayload: parseJsonObject(voucher.raw_payload_json),
createdAt: voucher.created_at,
updatedAt: voucher.updated_at,
}
}
function mapTaskReference(task: TaskRow) {
return {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
deliveryStatus: task.delivery_status,
}
}
function normalizeAdminEtickets(value: unknown): JsonObject[] {
if (!Array.isArray(value)) {
return []
}
const items: JsonObject[] = []
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const current = item as JsonObject
const id = String(current.id || '').trim()
const code = String(current.code || '').trim()
const num = Number(current.num || 1)
if (!id && !code) {
continue
}
items.push({
...(id ? { id } : {}),
...(code ? { code } : {}),
num: Number.isInteger(num) && num > 0 ? num : 1,
})
}
return items
}
function assertRequired(value: unknown, message: string) {
if (value === undefined || value === null || String(value).trim() === '') {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_missing_required_field',
})
}
}
function assertEtickets(value: unknown, message: string) {
if (!Array.isArray(value) || value.length === 0) {
throw createHttpError(message, {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_missing_etickets',
})
}
}
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 : {}
} catch {
return {}
}
}
function parseJsonArray(value: unknown): JsonObject[] {
if (Array.isArray(value)) {
return value.filter((item): item is JsonObject =>
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
)
}
try {
const parsed = JSON.parse(String(value || '[]'))
return Array.isArray(parsed)
? parsed.filter((item): item is JsonObject =>
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
)
: []
} catch {
return []
}
}
@@ -0,0 +1,72 @@
import { requestKuaishouIndustryOpenApi } from './openapi-client.js'
import {
normalizeOpenApiLong,
normalizeOpenApiPositiveInteger,
pickDefinedBizParams,
type JsonObject,
} from './openapi-values.js'
export type KuaishouIndustryAvailableEticketInput = {
id?: unknown
code?: unknown
num?: unknown
}
export type KuaishouIndustryCheckAvailableInput = {
sellerId?: unknown
buyerId?: unknown
orderId?: unknown
eticketType?: unknown
bizTypeCode?: unknown
etickets?: unknown
eTicketList?: unknown
}
export function checkKuaishouIndustryEticketAvailable(
input: KuaishouIndustryCheckAvailableInput = {},
) {
const eticketType = String(input.eticketType || input.bizTypeCode || '').trim()
const etickets = normalizeAvailableEtickets(input.etickets || input.eTicketList)
const sellerId = normalizeOpenApiLong(input.sellerId)
const bizParams = pickDefinedBizParams({
buyerId: normalizeOpenApiLong(input.buyerId),
eticketType,
etickets,
orderId: normalizeOpenApiLong(input.orderId),
sellerId,
})
return requestKuaishouIndustryOpenApi({
apiMethod: 'open.virtual.eticket.checkavailable',
path: '/open/virtual/eticket/checkavailable',
bizParams,
...(sellerId ? { sellerId: String(sellerId) } : {}),
})
}
function normalizeAvailableEtickets(value: unknown): JsonObject[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => normalizeAvailableEticket(item))
.filter((item): item is JsonObject => Boolean(item))
}
function normalizeAvailableEticket(value: unknown): JsonObject | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const current = value as KuaishouIndustryAvailableEticketInput
const id = String(current.id || '').trim()
const code = String(current.code || '').trim()
const num = normalizeOpenApiPositiveInteger(current.num, 1)
if (!id && !code) {
return null
}
return pickDefinedBizParams({ id, code, num })
}
@@ -1,12 +1,5 @@
import crypto from 'node:crypto'
import { logInfo, logWarn } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any>
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js'
type ConsumeCallbackInput = {
oid: string
@@ -33,38 +26,9 @@ type ConsumeCallbackInput = {
consumePoiId?: number
}
export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
let config = getKuaishouIndustryConfig()
if (!config.enabled) {
logInfo('[kuaishou-industry/consume-callback]', '行业电子凭证配置未启用,跳过核销回调')
return { success: true }
}
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({
config,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', {
error: message,
})
return { success: false, error: message }
}
if (!config.accessToken) {
const message = 'accessToken 未配置,无法发起核销回调'
logWarn('[kuaishou-industry/consume-callback]', message)
return { success: false, error: message }
}
export async function consumeCallback(
input: ConsumeCallbackInput,
): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
const bizParams: JsonObject = {
oid: input.oid,
etickets: input.etickets.map((e) => {
@@ -90,121 +54,63 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su
if (input.seriallNum) bizParams.seriallNum = input.seriallNum
if (input.consumePoiId != null) bizParams.consumePoiId = input.consumePoiId
const paramStr = JSON.stringify(bizParams)
const signParams: JsonObject = {
method: 'integration.callback.virtual.eticket.consume',
appkey: config.appKey,
access_token: config.accessToken,
version: '1',
timestamp: Date.now(),
signMethod: 'MD5',
param: paramStr,
}
const sign = signPayload(signParams, config.signSecret)
signParams.sign = sign
const body = new URLSearchParams()
for (const [key, value] of Object.entries(signParams)) {
body.append(key, String(value))
}
const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/consume`
logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, {
url,
oid: input.oid,
sellerId: input.sellerId || '',
status: input.status,
consumeType: input.consumeType,
const result = await requestKuaishouIndustryOpenApi({
apiMethod: 'integration.callback.virtual.eticket.consume',
path: '/integration/callback/virtual/eticket/consume',
bizParams,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
onRequest: (request) => {
logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, {
...request,
oid: input.oid,
sellerId: input.sellerId || '',
status: input.status,
consumeType: input.consumeType,
})
},
})
try {
const startedAt = Date.now()
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
if (result.skippedReason === 'disabled') {
logInfo('[kuaishou-industry/consume-callback]', '行业电子凭证配置未启用,跳过核销回调')
return { success: true }
}
if (result.skippedReason === 'token_error') {
const message = result.error || 'accessToken 刷新失败'
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', {
error: message,
})
const text = await res.text()
let json: JsonObject = {}
try { json = JSON.parse(text) } catch { json = { raw: text } }
const durationMs = Date.now() - startedAt
const ok = res.ok && Number(json.result) === 1
if (ok) {
logInfo('[kuaishou-industry/consume-callback]', `核销回调成功 oid=${input.oid}`, {
durationMs,
response: json,
})
} else {
logWarn('[kuaishou-industry/consume-callback]', `核销回调失败 oid=${input.oid}`, {
durationMs,
status: res.status,
response: json,
})
}
return { success: ok, response: json }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logWarn('[kuaishou-industry/consume-callback]', `核销回调异常 oid=${input.oid}`, resolveCallbackErrorDetail(err))
return { success: false, error: message }
}
}
function signPayload(params: JsonObject, signSecret: string): string {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
const queryString = entries
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
.join('&')
const source = `${queryString}&signSecret=${signSecret}`
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
}
function stringifySignValue(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (value == null) return ''
if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort())
return String(value)
}
function resolveCallbackErrorDetail(error: unknown): JsonObject {
const detail: JsonObject = {
error: error instanceof Error ? error.message : String(error),
}
const cause = error && typeof error === 'object' && 'cause' in error
? (error as { cause?: unknown }).cause
: null
if (!cause || typeof cause !== 'object') {
return detail
if (result.skippedReason === 'missing_access_token') {
const message = 'accessToken 未配置,无法发起核销回调'
logWarn('[kuaishou-industry/consume-callback]', message)
return { success: false, error: message }
}
const current = cause as Record<string, unknown>
const fields: Array<[string, unknown]> = [
['causeMessage', current.message],
['causeCode', current.code],
['causeSyscall', current.syscall],
['causeHostname', current.hostname],
]
for (const [key, value] of fields) {
const text = String(value || '').trim()
if (text) {
detail[key] = text
}
if (result.success) {
logInfo('[kuaishou-industry/consume-callback]', `核销回调成功 oid=${input.oid}`, {
durationMs: result.durationMs,
response: result.response || null,
})
} else if (result.error) {
logWarn(
'[kuaishou-industry/consume-callback]',
`核销回调异常 oid=${input.oid}`,
result.errorDetail || { error: result.error },
)
} else {
logWarn('[kuaishou-industry/consume-callback]', `核销回调失败 oid=${input.oid}`, {
durationMs: result.durationMs,
status: result.httpStatus,
response: result.response || null,
})
}
return detail
}
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
return {
success: result.success,
...(result.response ? { response: result.response } : {}),
...(result.error ? { error: result.error } : {}),
}
}
@@ -6,7 +6,7 @@ import { assertKuaishouIndustryConfig } from './config.js'
type JsonObject = Record<string, any>
type SignMethod = 'MD5' | 'HMAC_SHA256'
export type SignMethod = 'MD5' | 'HMAC_SHA256'
export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signSecret } = assertKuaishouIndustryConfig()) {
const entries = Object.entries(params)
@@ -1,12 +1,5 @@
import crypto from 'node:crypto'
import { logInfo, logWarn } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any>
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js'
type DestroyCallbackInput = {
oid: string
@@ -23,38 +16,9 @@ type DestroyCallbackInput = {
token?: string
}
export async function destroyCallback(input: DestroyCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
let config = getKuaishouIndustryConfig()
if (!config.enabled) {
logInfo('[kuaishou-industry/destroy-callback]', '行业电子凭证配置未启用,跳过销毁回调')
return { success: true }
}
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({
config,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', {
error: message,
})
return { success: false, error: message }
}
if (!config.accessToken) {
const message = 'accessToken 未配置,无法发起销毁回调'
logWarn('[kuaishou-industry/destroy-callback]', message)
return { success: false, error: message }
}
export async function destroyCallback(
input: DestroyCallbackInput,
): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
const bizParams: JsonObject = {
oid: input.oid,
reason: input.reason,
@@ -73,121 +37,63 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su
if (input.ext) bizParams.ext = input.ext
if (input.token) bizParams.token = input.token
const paramStr = JSON.stringify(bizParams)
const signParams: JsonObject = {
method: 'integration.callback.virtual.eticket.destroy',
appkey: config.appKey,
access_token: config.accessToken,
version: '1',
timestamp: Date.now(),
signMethod: 'MD5',
param: paramStr,
}
const sign = signPayload(signParams, config.signSecret)
signParams.sign = sign
const body = new URLSearchParams()
for (const [key, value] of Object.entries(signParams)) {
body.append(key, String(value))
}
const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/destroy`
logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, {
url,
oid: input.oid,
sellerId: input.sellerId || '',
reason: input.reason,
hasToken: Boolean(input.token),
const result = await requestKuaishouIndustryOpenApi({
apiMethod: 'integration.callback.virtual.eticket.destroy',
path: '/integration/callback/virtual/eticket/destroy',
bizParams,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
onRequest: (request) => {
logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, {
...request,
oid: input.oid,
sellerId: input.sellerId || '',
reason: input.reason,
hasToken: Boolean(input.token),
})
},
})
try {
const startedAt = Date.now()
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
if (result.skippedReason === 'disabled') {
logInfo('[kuaishou-industry/destroy-callback]', '行业电子凭证配置未启用,跳过销毁回调')
return { success: true }
}
if (result.skippedReason === 'token_error') {
const message = result.error || 'accessToken 刷新失败'
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', {
error: message,
})
const text = await res.text()
let json: JsonObject = {}
try { json = JSON.parse(text) } catch { json = { raw: text } }
const durationMs = Date.now() - startedAt
const ok = res.ok && Number(json.result) === 1
if (ok) {
logInfo('[kuaishou-industry/destroy-callback]', `销毁回调成功 oid=${input.oid}`, {
durationMs,
response: json,
})
} else {
logWarn('[kuaishou-industry/destroy-callback]', `销毁回调失败 oid=${input.oid}`, {
durationMs,
status: res.status,
response: json,
})
}
return { success: ok, response: json }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logWarn('[kuaishou-industry/destroy-callback]', `销毁回调异常 oid=${input.oid}`, resolveCallbackErrorDetail(err))
return { success: false, error: message }
}
}
function signPayload(params: JsonObject, signSecret: string): string {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
const queryString = entries
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
.join('&')
const source = `${queryString}&signSecret=${signSecret}`
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
}
function stringifySignValue(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (value == null) return ''
if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort())
return String(value)
}
function resolveCallbackErrorDetail(error: unknown): JsonObject {
const detail: JsonObject = {
error: error instanceof Error ? error.message : String(error),
}
const cause = error && typeof error === 'object' && 'cause' in error
? (error as { cause?: unknown }).cause
: null
if (!cause || typeof cause !== 'object') {
return detail
if (result.skippedReason === 'missing_access_token') {
const message = 'accessToken 未配置,无法发起销毁回调'
logWarn('[kuaishou-industry/destroy-callback]', message)
return { success: false, error: message }
}
const current = cause as Record<string, unknown>
const fields: Array<[string, unknown]> = [
['causeMessage', current.message],
['causeCode', current.code],
['causeSyscall', current.syscall],
['causeHostname', current.hostname],
]
for (const [key, value] of fields) {
const text = String(value || '').trim()
if (text) {
detail[key] = text
}
if (result.success) {
logInfo('[kuaishou-industry/destroy-callback]', `销毁回调成功 oid=${input.oid}`, {
durationMs: result.durationMs,
response: result.response || null,
})
} else if (result.error) {
logWarn(
'[kuaishou-industry/destroy-callback]',
`销毁回调异常 oid=${input.oid}`,
result.errorDetail || { error: result.error },
)
} else {
logWarn('[kuaishou-industry/destroy-callback]', `销毁回调失败 oid=${input.oid}`, {
durationMs: result.durationMs,
status: result.httpStatus,
response: result.response || null,
})
}
return detail
}
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
return {
success: result.success,
...(result.response ? { response: result.response } : {}),
...(result.error ? { error: result.error } : {}),
}
}
@@ -0,0 +1,195 @@
import { getKuaishouIndustryConfig } from './config.js'
import { signKuaishouIndustryPayload, type SignMethod } from './crypto.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
export type JsonObject = Record<string, any>
type KuaishouIndustryOpenApiCallInput = {
apiMethod: string
path: string
bizParams: JsonObject
httpMethod?: 'GET' | 'POST'
sellerId?: string
signMethod?: SignMethod
onRequest?: (request: JsonObject) => void
}
export type KuaishouIndustryOpenApiCallResult = {
success: boolean
response?: JsonObject
error?: string
request?: JsonObject
durationMs?: number
httpStatus?: number
skippedReason?: 'disabled' | 'missing_access_token' | 'token_error'
errorDetail?: JsonObject
}
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
export async function requestKuaishouIndustryOpenApi(
input: KuaishouIndustryOpenApiCallInput,
): Promise<KuaishouIndustryOpenApiCallResult> {
let config = getKuaishouIndustryConfig()
if (!config.enabled) {
return { success: true, skippedReason: 'disabled' }
}
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({
config,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
return {
success: false,
skippedReason: 'token_error',
error: error instanceof Error ? error.message : String(error),
errorDetail: resolveKuaishouOpenApiErrorDetail(error),
}
}
if (!config.accessToken) {
return {
success: false,
skippedReason: 'missing_access_token',
error: 'accessToken 未配置',
}
}
const signMethod = input.signMethod || 'MD5'
const paramStr = JSON.stringify(input.bizParams)
const signParams: JsonObject = {
method: input.apiMethod,
appkey: config.appKey,
access_token: config.accessToken,
version: config.version || '1',
timestamp: Date.now(),
signMethod,
param: paramStr,
}
signParams.sign = signKuaishouIndustryPayload(signParams, signMethod, config)
const body = new URLSearchParams()
for (const [key, value] of Object.entries(signParams)) {
body.append(key, String(value))
}
const httpMethod = input.httpMethod || 'POST'
const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}${normalizeOpenApiPath(input.path)}`
const requestLog = buildKuaishouOpenApiRequestLog({
url,
signParams,
bizParams: input.bizParams,
httpMethod,
})
input.onRequest?.(requestLog)
try {
const startedAt = Date.now()
const res = httpMethod === 'GET'
? await fetch(`${url}?${body.toString()}`, { method: 'GET' })
: await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
const text = await res.text()
let json: JsonObject = {}
try {
json = JSON.parse(text)
} catch {
json = { raw: text }
}
const durationMs = Date.now() - startedAt
return {
success: res.ok && Number(json.result) === 1,
response: json,
request: requestLog,
durationMs,
httpStatus: res.status,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
request: requestLog,
errorDetail: resolveKuaishouOpenApiErrorDetail(error),
}
}
}
export function buildKuaishouOpenApiRequestLog({
url,
signParams,
bizParams,
httpMethod,
}: {
url: string
signParams: JsonObject
bizParams: JsonObject
httpMethod?: 'GET' | 'POST'
}): JsonObject {
return {
url,
method: httpMethod || 'POST',
contentType: 'application/x-www-form-urlencoded',
apiMethod: signParams.method,
appkey: signParams.appkey,
access_token: signParams.access_token,
version: signParams.version,
timestamp: signParams.timestamp,
signMethod: signParams.signMethod,
sign: signParams.sign,
param: bizParams,
}
}
export function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') ||
DEFAULT_KUAISHOU_OPEN_API
}
export function resolveKuaishouOpenApiErrorDetail(error: unknown): JsonObject {
const detail: JsonObject = {
error: error instanceof Error ? error.message : String(error),
}
const cause = error && typeof error === 'object' && 'cause' in error
? (error as { cause?: unknown }).cause
: null
if (!cause || typeof cause !== 'object') {
return detail
}
const current = cause as Record<string, unknown>
const fields: Array<[string, unknown]> = [
['causeMessage', current.message],
['causeCode', current.code],
['causeSyscall', current.syscall],
['causeHostname', current.hostname],
]
for (const [key, value] of fields) {
const text = String(value || '').trim()
if (text) {
detail[key] = text
}
}
return detail
}
function normalizeOpenApiPath(value: unknown): string {
const path = String(value || '').trim()
if (!path) {
return '/'
}
return path.startsWith('/') ? path : `/${path}`
}
@@ -0,0 +1,53 @@
export type JsonObject = Record<string, any>
export function pickDefinedBizParams(input: JsonObject): JsonObject {
const output: JsonObject = {}
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) {
continue
}
if (typeof value === 'string' && value.trim() === '') {
continue
}
output[key] = value
}
return output
}
export function normalizeOpenApiLong(value: unknown): string | number {
const text = String(value ?? '').trim()
if (!text) {
return ''
}
if (!/^\d+$/.test(text)) {
return text
}
const parsed = Number(text)
return Number.isSafeInteger(parsed) ? parsed : text
}
export function normalizeOpenApiInteger(value: unknown, fallback = 0): number {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
export function normalizeOpenApiPositiveInteger(value: unknown, fallback = 1): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
export function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => String(item || '').trim())
.filter(Boolean)
}
@@ -0,0 +1,114 @@
import { requestKuaishouIndustryOpenApi } from './openapi-client.js'
import {
normalizeOpenApiInteger,
normalizeOpenApiLong,
normalizeOpenApiPositiveInteger,
normalizeStringList,
pickDefinedBizParams,
type JsonObject,
} from './openapi-values.js'
export type KuaishouIndustryRefundListInput = {
sellerId?: string
beginTime?: unknown
endTime?: unknown
type?: unknown
pageSize?: unknown
currentPage?: unknown
sort?: unknown
queryType?: unknown
negotiateStatus?: unknown
pcursor?: unknown
status?: unknown
option?: JsonObject
orderId?: unknown
}
export type KuaishouIndustryRefundApproveInput = {
sellerId?: string
refundId?: unknown
desc?: unknown
refundAmount?: unknown
status?: unknown
negotiateStatus?: unknown
refundHandingWay?: unknown
}
export type KuaishouIndustryRefundDisagreeInput = {
sellerId?: string
refundId?: unknown
sellerDisagreeReason?: unknown
sellerDisagreeDesc?: unknown
sellerDisagreeImages?: unknown
status?: unknown
negotiateStatus?: unknown
}
export function listKuaishouIndustryRefunds(input: KuaishouIndustryRefundListInput = {}) {
const bizParams = pickDefinedBizParams({
beginTime: normalizeOpenApiLong(input.beginTime),
endTime: normalizeOpenApiLong(input.endTime),
type: normalizeOpenApiInteger(input.type, 8),
pageSize: normalizeOpenApiPositiveInteger(input.pageSize, 50),
currentPage: normalizeOpenApiPositiveInteger(input.currentPage, 1),
sort: input.sort === undefined || input.sort === '' ? undefined : normalizeOpenApiInteger(input.sort, 1),
queryType: input.queryType === undefined || input.queryType === ''
? undefined
: normalizeOpenApiInteger(input.queryType, 1),
negotiateStatus: input.negotiateStatus === undefined || input.negotiateStatus === ''
? undefined
: normalizeOpenApiInteger(input.negotiateStatus, 0),
pcursor: String(input.pcursor ?? ''),
status: input.status === undefined || input.status === '' ? undefined : normalizeOpenApiInteger(input.status, 0),
option: input.option && typeof input.option === 'object' ? input.option : undefined,
orderId: normalizeOpenApiLong(input.orderId),
})
return requestKuaishouIndustryOpenApi({
apiMethod: 'open.seller.order.refund.pcursor.list',
path: '/open/seller/order/refund/pcursor/list',
httpMethod: 'GET',
bizParams,
...(input.sellerId ? { sellerId: String(input.sellerId).trim() } : {}),
})
}
export function approveKuaishouIndustryRefund(input: KuaishouIndustryRefundApproveInput = {}) {
const bizParams = pickDefinedBizParams({
refundId: normalizeOpenApiLong(input.refundId),
desc: String(input.desc ?? '').trim(),
refundAmount: normalizeOpenApiLong(input.refundAmount),
status: input.status === undefined || input.status === '' ? undefined : normalizeOpenApiInteger(input.status, 0),
negotiateStatus: input.negotiateStatus === undefined || input.negotiateStatus === ''
? undefined
: normalizeOpenApiInteger(input.negotiateStatus, 0),
refundHandingWay: input.refundHandingWay === undefined || input.refundHandingWay === ''
? undefined
: normalizeOpenApiInteger(input.refundHandingWay, 0),
})
return requestKuaishouIndustryOpenApi({
apiMethod: 'open.seller.order.refund.approve',
path: '/open/seller/order/refund/approve',
bizParams,
...(input.sellerId ? { sellerId: String(input.sellerId).trim() } : {}),
})
}
export function disagreeKuaishouIndustryRefund(input: KuaishouIndustryRefundDisagreeInput = {}) {
const bizParams = pickDefinedBizParams({
refundId: normalizeOpenApiLong(input.refundId),
sellerDisagreeReason: normalizeOpenApiInteger(input.sellerDisagreeReason, 100),
sellerDisagreeDesc: String(input.sellerDisagreeDesc ?? '').trim(),
sellerDisagreeImages: normalizeStringList(input.sellerDisagreeImages),
status: normalizeOpenApiInteger(input.status, 10),
negotiateStatus: normalizeOpenApiInteger(input.negotiateStatus, 1),
})
return requestKuaishouIndustryOpenApi({
apiMethod: 'open.seller.order.refund.disagree.refund',
path: '/open/seller/order/refund/disagree/refund',
bizParams,
...(input.sellerId ? { sellerId: String(input.sellerId).trim() } : {}),
})
}
@@ -0,0 +1,82 @@
import { requestKuaishouIndustryOpenApi } from './openapi-client.js'
import {
normalizeOpenApiPositiveInteger,
pickDefinedBizParams,
type JsonObject,
} from './openapi-values.js'
export type KuaishouIndustryReverseEticketInput = {
id?: unknown
code?: unknown
num?: unknown
}
export type KuaishouIndustryReverseCallbackInput = {
sellerId?: string
oid?: unknown
eticketType?: unknown
etickets?: unknown
serialNum?: unknown
reason?: unknown
ext?: unknown
token?: unknown
}
export function reverseKuaishouIndustryCallback(input: KuaishouIndustryReverseCallbackInput = {}) {
const bizParams = pickDefinedBizParams({
oid: String(input.oid || '').trim(),
eticketType: String(input.eticketType || '').trim(),
etickets: normalizeReverseEtickets(input.etickets),
serialNum: String(input.serialNum || '').trim(),
reason: String(input.reason || '').trim(),
ext: normalizeReverseExt(input.ext),
token: String(input.token || '').trim(),
})
return requestKuaishouIndustryOpenApi({
apiMethod: 'integration.callback.virtual.eticket.reverse',
path: '/integration/callback/virtual/eticket/reverse',
bizParams,
...(input.sellerId ? { sellerId: String(input.sellerId).trim() } : {}),
})
}
function normalizeReverseEtickets(value: unknown): JsonObject[] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => normalizeReverseEticket(item))
.filter((item): item is JsonObject => Boolean(item))
}
function normalizeReverseEticket(value: unknown): JsonObject | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const current = value as KuaishouIndustryReverseEticketInput
const id = String(current.id || '').trim()
const code = String(current.code || '').trim()
const num = normalizeOpenApiPositiveInteger(current.num, 1)
if (!id && !code) {
return null
}
return pickDefinedBizParams({ id, code, num })
}
function normalizeReverseExt(value: unknown): unknown {
if (!value) {
return undefined
}
if (typeof value === 'string') {
const text = value.trim()
return text || undefined
}
return value
}
@@ -1,12 +1,5 @@
import crypto from 'node:crypto'
import { logIntegration } from '../../../utils/logger.js'
import { getKuaishouIndustryConfig } from './config.js'
import { ensureKuaishouIndustryAccessToken } from './token-service.js'
type JsonObject = Record<string, any>
const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js'
type SendCallbackInput = {
oid: string
@@ -29,37 +22,6 @@ type SendCallbackInput = {
}
export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
let config = getKuaishouIndustryConfig()
if (!config.enabled) {
logIntegration('[kuaishou-industry/send-callback]', '行业电子凭证配置未启用,跳过发货回调')
return { success: true }
}
try {
const tokenResult = await ensureKuaishouIndustryAccessToken({
config,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
config = {
...config,
...tokenResult.config,
accessToken: tokenResult.accessToken,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logIntegration('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发货回调', {
error: message,
}, { level: 'warn' })
return { success: false, error: message }
}
if (!config.accessToken) {
const message = 'accessToken 未配置,无法发起发货回调'
logIntegration('[kuaishou-industry/send-callback]', message, undefined, { level: 'warn' })
return { success: false, error: message }
}
const bizParams: JsonObject = {
oid: input.oid,
sendType: input.sendType,
@@ -83,153 +45,70 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
if (input.expressCode) bizParams.expressCode = input.expressCode
if (input.expressNo) bizParams.expressNo = input.expressNo
const paramStr = JSON.stringify(bizParams)
const result = await requestKuaishouIndustryOpenApi({
apiMethod: 'integration.callback.virtual.eticket.send',
path: '/integration/callback/virtual/eticket/send',
bizParams,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
onRequest: (request) => {
const requestLog = {
sellerId: input.sellerId || '',
...request,
}
logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog)
},
})
const signParams: JsonObject = {
method: 'integration.callback.virtual.eticket.send',
appkey: config.appKey,
access_token: config.accessToken,
version: '1',
timestamp: Date.now(),
signMethod: 'MD5',
param: paramStr,
if (result.skippedReason === 'disabled') {
logIntegration('[kuaishou-industry/send-callback]', '行业电子凭证配置未启用,跳过发货回调')
return { success: true }
}
const sign = signPayload(signParams, config.signSecret)
signParams.sign = sign
const body = new URLSearchParams()
for (const [key, value] of Object.entries(signParams)) {
body.append(key, String(value))
}
const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/send`
const requestLog = {
sellerId: input.sellerId || '',
...buildSendCallbackRequestLog({
url,
signParams,
bizParams,
}),
}
logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog)
try {
const startedAt = Date.now()
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
const text = await res.text()
let json: JsonObject = {}
try { json = JSON.parse(text) } catch { json = { raw: text } }
const durationMs = Date.now() - startedAt
const ok = res.ok && Number(json.result) === 1
if (ok) {
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调成功 oid=${input.oid}`, {
durationMs,
status: res.status,
request: requestLog,
response: json,
})
} else {
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调失败 oid=${input.oid}`, {
durationMs,
status: res.status,
request: requestLog,
response: json,
}, { level: 'warn' })
}
return { success: ok, response: json }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调异常 oid=${input.oid}`, {
...resolveCallbackErrorDetail(err),
request: requestLog,
if (result.skippedReason === 'token_error') {
const message = result.error || 'accessToken 刷新失败'
logIntegration('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发货回调', {
error: message,
}, { level: 'warn' })
return { success: false, error: message }
}
}
function buildSendCallbackRequestLog({
url,
signParams,
bizParams,
}: {
url: string
signParams: JsonObject
bizParams: JsonObject
}): JsonObject {
if (result.skippedReason === 'missing_access_token') {
const message = 'accessToken 未配置,无法发起发货回调'
logIntegration('[kuaishou-industry/send-callback]', message, undefined, { level: 'warn' })
return { success: false, error: message }
}
const requestLog = result.request
? {
sellerId: input.sellerId || '',
...result.request,
}
: undefined
if (result.success) {
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调成功 oid=${input.oid}`, {
durationMs: result.durationMs,
status: result.httpStatus,
request: requestLog,
response: result.response || null,
})
} else if (result.error) {
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调异常 oid=${input.oid}`, {
...(result.errorDetail || { error: result.error }),
request: requestLog,
}, { level: 'warn' })
} else {
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调失败 oid=${input.oid}`, {
durationMs: result.durationMs,
status: result.httpStatus,
request: requestLog,
response: result.response || null,
}, { level: 'warn' })
}
return {
url,
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
apiMethod: signParams.method,
appkey: signParams.appkey,
access_token: signParams.access_token,
version: signParams.version,
timestamp: signParams.timestamp,
signMethod: signParams.signMethod,
sign: signParams.sign,
param: bizParams,
success: result.success,
...(result.response ? { response: result.response } : {}),
...(result.error ? { error: result.error } : {}),
}
}
function signPayload(params: JsonObject, signSecret: string): string {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
const queryString = entries
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
.join('&')
const source = `${queryString}&signSecret=${signSecret}`
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
}
function stringifySignValue(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (value == null) return ''
if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort())
return String(value)
}
function resolveCallbackErrorDetail(error: unknown): JsonObject {
const detail: JsonObject = {
error: error instanceof Error ? error.message : String(error),
}
const cause = error && typeof error === 'object' && 'cause' in error
? (error as { cause?: unknown }).cause
: null
if (!cause || typeof cause !== 'object') {
return detail
}
const current = cause as Record<string, unknown>
const fields: Array<[string, unknown]> = [
['causeMessage', current.message],
['causeCode', current.code],
['causeSyscall', current.syscall],
['causeHostname', current.hostname],
]
for (const [key, value] of fields) {
const text = String(value || '').trim()
if (text) {
detail[key] = text
}
}
return detail
}
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -12,8 +12,15 @@ import type {
AdminCloudtentaclesSkuBuyInput,
AdminCloudtentaclesSkuUseInput,
AdminCloudtentaclesSourceConfigInput,
AdminKuaishouIndustryRefundApproveInput,
AdminKuaishouIndustryRefundDisagreeInput,
AdminKuaishouIndustryRefundListInput,
AdminKuaishouIndustryAuthorizationCodeInput,
AdminKuaishouIndustrySourceConfigInput,
AdminKuaishouIndustryVoucherCheckAvailableInput,
AdminKuaishouIndustryVoucherConsumeInput,
AdminKuaishouIndustryVoucherResendInput,
AdminKuaishouIndustryVoucherReverseInput,
AdminCloudtentaclesTestLoginInput,
AdminCloudtentaclesValidateSessionInput,
AdminCloudtentaclesVirtualNumberInput,
@@ -36,6 +43,13 @@ export type AdminRouteAdminSession = AdminViewerSessionInput
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
export type AdminKuaishouIndustrySourceConfigRouteBody = AdminKuaishouIndustrySourceConfigInput
export type AdminKuaishouIndustryAuthorizationCodeRouteBody = AdminKuaishouIndustryAuthorizationCodeInput
export type AdminKuaishouIndustryRefundListRouteBody = AdminKuaishouIndustryRefundListInput
export type AdminKuaishouIndustryRefundApproveRouteBody = AdminKuaishouIndustryRefundApproveInput
export type AdminKuaishouIndustryRefundDisagreeRouteBody = AdminKuaishouIndustryRefundDisagreeInput
export type AdminKuaishouIndustryVoucherCheckAvailableRouteBody = AdminKuaishouIndustryVoucherCheckAvailableInput
export type AdminKuaishouIndustryVoucherReverseRouteBody = AdminKuaishouIndustryVoucherReverseInput
export type AdminKuaishouIndustryVoucherConsumeRouteBody = AdminKuaishouIndustryVoucherConsumeInput
export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryVoucherResendInput
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput
@@ -285,3 +285,85 @@ export type AdminTaskKuaishouIndustryConsumeInput = {
expressCode?: string
expressNo?: string
}
export type AdminKuaishouIndustryRefundListInput = {
sellerId?: string
beginTime?: number | string
endTime?: number | string
type?: number | string
pageSize?: number | string
currentPage?: number | string
sort?: number | string
queryType?: number | string
negotiateStatus?: number | string
pcursor?: string
status?: number | string
orderId?: string
option?: Record<string, unknown>
}
export type AdminKuaishouIndustryRefundApproveInput = {
sellerId?: string
refundId?: number | string
desc?: string
refundAmount?: number | string
status?: number | string
negotiateStatus?: number | string
refundHandingWay?: number | string
}
export type AdminKuaishouIndustryRefundDisagreeInput = {
sellerId?: string
refundId?: number | string
sellerDisagreeReason?: number | string
sellerDisagreeDesc?: string
sellerDisagreeImages?: string[]
status?: number | string
negotiateStatus?: number | string
}
export type AdminKuaishouIndustryVoucherCheckAvailableInput = {
sellerId?: string
buyerId?: string
orderId?: string
oid?: string
voucherCode?: string
eticketType?: string
bizTypeCode?: string
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
}
export type AdminKuaishouIndustryVoucherReverseInput = {
sellerId?: string
oid?: string
orderId?: string
voucherCode?: string
eticketType?: string
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
serialNum?: string
reason?: string
ext?: string | Record<string, unknown>
token?: string
}
export type AdminKuaishouIndustryVoucherConsumeInput = {
oid?: string
orderId?: string
taskId?: number | string
voucherCode?: string
token?: string
eticketType?: string
consumeType?: string
serialNum?: string
storeName?: string
storeAddress?: string
expressCode?: string
expressNo?: string
}
export type AdminKuaishouIndustryVoucherResendInput = {
oid?: string
orderId?: string
taskId?: number | string
voucherCode?: string
}
+2
View File
@@ -10,6 +10,7 @@ const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')
const AdminCloudtentaclesRecordsPage = lazy(
() => import('@/pages/admin/AdminCloudtentaclesRecordsPage'),
)
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
@@ -64,6 +65,7 @@ export default function App() {
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
<Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
<Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} />
@@ -6,6 +6,7 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
OrderedListOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShopOutlined,
TeamOutlined,
@@ -44,6 +45,7 @@ export default function AdminLayout() {
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{ key: '/admin/kuaishou-industry', icon: <SafetyCertificateOutlined />, label: '电子凭证' },
{ key: '/admin/cloudtentacles-records', icon: <FileSearchOutlined />, label: '发货记录' },
]
@@ -181,6 +183,7 @@ export default function AdminLayout() {
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
if (pathname === '/admin/platform-shops') return '/admin/platform-shops'
return pathname
}
@@ -0,0 +1,834 @@
import {
CheckCircleOutlined,
ReloadOutlined,
RollbackOutlined,
SearchOutlined,
SendOutlined,
SyncOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
DatePicker,
Input,
InputNumber,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { showError, showSuccess } from '@/lib/feedback'
import {
approveAdminKuaishouIndustryRefund,
checkAdminKuaishouIndustryVoucherAvailable,
consumeAdminKuaishouIndustryVoucher,
disagreeAdminKuaishouIndustryRefund,
fetchAdminKuaishouIndustryVouchers,
listAdminKuaishouIndustryRefunds,
resendAdminKuaishouIndustryVoucherCode,
reverseAdminKuaishouIndustryVoucher,
} from '@/services/admin'
import type {
AdminKuaishouIndustryOpenApiResult,
AdminKuaishouIndustryRefundApprovePayload,
AdminKuaishouIndustryRefundDisagreePayload,
AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryVoucher,
AdminKuaishouIndustryVoucherToolPayload,
} from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { stringifyDisplayJson } from '@/utils/date-time'
type DateRangeValue = [Dayjs | null, Dayjs | null] | null
type VoucherFilterState = {
oid: string
voucherCode: string
taskId: string
sellerId: string
status: string
page: number
pageSize: number
}
type RefundListState = {
sellerId: string
orderId: string
type: string
pageSize: string
currentPage: string
pcursor: string
status: string
negotiateStatus: string
}
const DEFAULT_ETICKET_TYPE = 'DINING_OPEN_TICKET'
export default function AdminKuaishouIndustryPage() {
const [voucherLoading, setVoucherLoading] = useState(false)
const [actionLoading, setActionLoading] = useState('')
const [vouchers, setVouchers] = useState<AdminKuaishouIndustryVoucher[]>([])
const [voucherTotal, setVoucherTotal] = useState(0)
const [voucherFilters, setVoucherFilters] = useState<VoucherFilterState>({
oid: '',
voucherCode: '',
taskId: '',
sellerId: '',
status: '',
page: 1,
pageSize: 50,
})
const [toolForm, setToolForm] = useState<AdminKuaishouIndustryVoucherToolPayload>({
eticketType: DEFAULT_ETICKET_TYPE,
consumeType: 'delivery',
})
const [refundDateRange, setRefundDateRange] = useState<DateRangeValue>(() => [
dayjs().subtract(1, 'day'),
dayjs(),
])
const [refundListForm, setRefundListForm] = useState<RefundListState>({
sellerId: '',
orderId: '',
type: '8',
pageSize: '50',
currentPage: '1',
pcursor: '',
status: '10',
negotiateStatus: '',
})
const [approveForm, setApproveForm] = useState<AdminKuaishouIndustryRefundApprovePayload>({
refundId: '',
refundAmount: '',
status: '',
negotiateStatus: '',
refundHandingWay: '10',
})
const [disagreeForm, setDisagreeForm] = useState<AdminKuaishouIndustryRefundDisagreePayload>({
refundId: '',
sellerDisagreeReason: '100',
sellerDisagreeDesc: '',
status: '10',
negotiateStatus: '1',
})
const [lastResultTitle, setLastResultTitle] = useState('接口结果')
const [lastResult, setLastResult] = useState<unknown>(null)
const [refundRows, setRefundRows] = useState<Array<Record<string, unknown>>>([])
const columns = useMemo<TableColumnsType<AdminKuaishouIndustryVoucher>>(
() => [
{
title: '券码',
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong copyable>
{row.voucherCode}
</Typography.Text>
<span className="muted">{row.unitIndex}</span>
</div>
),
},
{
title: '订单 / 任务',
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text copyable>{row.oid || '-'}</Typography.Text>
<span className="muted">
{row.taskId || '-'} / {row.orderId || '-'}
</span>
</div>
),
},
{
title: '状态',
width: 150,
render: (_, row) => (
<div className="cell-stack">
<Tag color={getVoucherStatusColor(row.status)}>{getVoucherStatusLabel(row.status)}</Tag>
<Tag color={row.sendCallbackStatus === 'success' ? 'green' : 'orange'}>
{row.sendCallbackStatus || '-'}
</Tag>
</div>
),
},
{
title: '卖家',
minWidth: 160,
render: (_, row) => (
<div className="cell-stack">
<span>{row.sellerId || '-'}</span>
<span className="muted">Token{row.tokenMasked || '-'}</span>
</div>
),
},
{
title: '核销',
minWidth: 180,
render: (_, row) => (
<div className="cell-stack">
<span>{row.consumeSerialNum || '-'}</span>
<span className="muted">{formatAdminDateTime(row.consumedAt)}</span>
</div>
),
},
{
title: '更新时间',
width: 170,
render: (_, row) => formatAdminDateTime(row.updatedAt),
},
{
title: '操作',
fixed: 'right',
width: 120,
render: (_, row) => (
<Button size="small" type="primary" ghost onClick={() => selectVoucher(row)}>
</Button>
),
},
],
[],
)
const refundColumns = useMemo<TableColumnsType<Record<string, unknown>>>(
() => [
{ title: '售后单', dataIndex: 'refundId', minWidth: 160 },
{ title: '订单', dataIndex: 'oid', minWidth: 160 },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '协商', dataIndex: 'negotiateStatus', width: 100 },
{ title: '金额', dataIndex: 'refundFee', width: 100 },
{
title: '提交时间',
minWidth: 170,
render: (_, row) => formatTimestamp(row.submitTime || row.createTime),
},
{
title: '操作',
fixed: 'right',
width: 120,
render: (_, row) => (
<Button size="small" onClick={() => fillRefundAction(row)}>
</Button>
),
},
],
[],
)
useEffect(() => {
void loadVouchers()
}, [])
async function loadVouchers(nextFilters: Partial<VoucherFilterState> = {}) {
const filters = { ...voucherFilters, ...nextFilters }
setVoucherFilters(filters)
setVoucherLoading(true)
try {
const response = await fetchAdminKuaishouIndustryVouchers({
oid: filters.oid.trim(),
voucherCode: filters.voucherCode.trim(),
taskId: filters.taskId.trim(),
sellerId: filters.sellerId.trim(),
status: filters.status,
page: filters.page,
pageSize: filters.pageSize,
})
setVouchers(response.data.items)
setVoucherTotal(response.data.pagination.total)
} catch (error) {
showError(error instanceof Error ? error.message : '查询电子凭证失败')
} finally {
setVoucherLoading(false)
}
}
function selectVoucher(row: AdminKuaishouIndustryVoucher) {
setToolForm((current) => ({
...current,
sellerId: row.sellerId,
oid: row.oid,
orderId: row.oid,
taskId: row.taskId || '',
voucherCode: row.voucherCode,
serialNum: row.consumeSerialNum || current.serialNum,
}))
showSuccess('已填入券码操作区')
}
async function runCheckAvailable() {
await runOpenApiAction('检查电子凭证有效性', 'check', () =>
checkAdminKuaishouIndustryVoucherAvailable(buildVoucherToolPayload()),
)
}
async function runReverse() {
await runOpenApiAction('电子凭证冲正回调', 'reverse', () =>
reverseAdminKuaishouIndustryVoucher({
...buildVoucherToolPayload(),
reason: toolForm.reason || '后台手动冲正',
}),
)
await loadVouchers()
}
async function runConsume() {
setActionLoading('consume')
try {
const response = await consumeAdminKuaishouIndustryVoucher(buildVoucherToolPayload())
setLastResultTitle('手动核销')
setLastResult(response.data)
showSuccess('手动核销已完成')
await loadVouchers()
} catch (error) {
showError(error instanceof Error ? error.message : '手动核销失败')
} finally {
setActionLoading('')
}
}
async function runResendCode() {
setActionLoading('resend')
try {
const response = await resendAdminKuaishouIndustryVoucherCode(buildVoucherToolPayload())
setLastResultTitle('重发发码回调')
setLastResult(response.data)
if (response.data.success) {
showSuccess('发码回调已重发')
} else {
showError(response.data.error || '发码回调重发失败')
}
await loadVouchers()
} catch (error) {
showError(error instanceof Error ? error.message : '重发发码回调失败')
} finally {
setActionLoading('')
}
}
async function runRefundList() {
const [begin, end] = refundDateRange || []
await runOpenApiAction('售后单列表', 'refund-list', () =>
listAdminKuaishouIndustryRefunds({
...refundListForm,
beginTime: begin?.valueOf(),
endTime: end?.valueOf(),
} satisfies AdminKuaishouIndustryRefundListPayload),
(result) => {
const rows = extractRefundRows(result.response)
setRefundRows(rows)
},
)
}
async function runApproveRefund() {
await runOpenApiAction('同意退款', 'refund-approve', () =>
approveAdminKuaishouIndustryRefund({
...approveForm,
sellerId: approveForm.sellerId || refundListForm.sellerId,
}),
)
}
async function runDisagreeRefund() {
await runOpenApiAction('不同意退款', 'refund-disagree', () =>
disagreeAdminKuaishouIndustryRefund({
...disagreeForm,
sellerId: disagreeForm.sellerId || refundListForm.sellerId,
}),
)
}
async function runOpenApiAction(
title: string,
loadingKey: string,
action: () => Promise<{ data: AdminKuaishouIndustryOpenApiResult }>,
afterSuccess?: (result: AdminKuaishouIndustryOpenApiResult) => void,
) {
setActionLoading(loadingKey)
try {
const response = await action()
setLastResultTitle(title)
setLastResult(response.data)
afterSuccess?.(response.data)
if (response.data.success) {
showSuccess(`${title}已执行`)
} else {
showError(response.data.error || `${title}返回失败`)
}
} catch (error) {
showError(error instanceof Error ? error.message : `${title}失败`)
} finally {
setActionLoading('')
}
}
function buildVoucherToolPayload(): AdminKuaishouIndustryVoucherToolPayload {
const voucherCode = String(toolForm.voucherCode || '').trim()
return {
...toolForm,
etickets: voucherCode ? [{ id: voucherCode, code: voucherCode, num: 1 }] : undefined,
}
}
function fillRefundAction(row: Record<string, unknown>) {
const refundId = String(row.refundId || '').trim()
const status = String(row.status || '').trim()
const negotiateStatus = String(row.negotiateStatus || '').trim()
setApproveForm((current) => ({
...current,
refundId,
status,
negotiateStatus,
refundAmount: String(row.refundFee || current.refundAmount || ''),
}))
setDisagreeForm((current) => ({
...current,
refundId,
status,
negotiateStatus: negotiateStatus || current.negotiateStatus,
}))
showSuccess('已填入退款操作区')
}
return (
<div className="page-stack kuaishou-industry-page">
<PageHeader title="快手电子凭证" description="行业电子凭证接口工具与售后处理" />
<Tabs
items={[
{
key: 'vouchers',
label: '券码操作',
children: renderVoucherTab(),
},
{
key: 'refunds',
label: '售后退款',
children: renderRefundTab(),
},
{
key: 'result',
label: '接口结果',
children: renderResultTab(),
},
]}
/>
</div>
)
function renderVoucherTab() {
return (
<div className="page-stack">
<Card>
<Space wrap className="filter-form">
<Input
allowClear
placeholder="订单号 oid"
value={voucherFilters.oid}
onChange={(event) => setVoucherFilters({ ...voucherFilters, oid: event.target.value })}
/>
<Input
allowClear
placeholder="券码"
value={voucherFilters.voucherCode}
onChange={(event) =>
setVoucherFilters({ ...voucherFilters, voucherCode: event.target.value })
}
/>
<Input
allowClear
placeholder="任务 ID"
value={voucherFilters.taskId}
onChange={(event) =>
setVoucherFilters({ ...voucherFilters, taskId: event.target.value })
}
/>
<Input
allowClear
placeholder="卖家 ID"
value={voucherFilters.sellerId}
onChange={(event) =>
setVoucherFilters({ ...voucherFilters, sellerId: event.target.value })
}
/>
<Select
allowClear
placeholder="券码状态"
style={{ width: 132 }}
value={voucherFilters.status || undefined}
options={[
{ label: '未使用', value: 'UNUSED' },
{ label: '已核销', value: 'CONSUMED' },
{ label: '已销毁', value: 'DESTROYED' },
]}
onChange={(value) => setVoucherFilters({ ...voucherFilters, status: value || '' })}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={voucherLoading}
onClick={() => loadVouchers({ page: 1 })}
>
</Button>
</Space>
</Card>
<div className="kuaishou-industry-split">
<Card title="本地券码">
<Table
rowKey="id"
columns={columns}
dataSource={vouchers}
loading={voucherLoading}
scroll={{ x: 1180 }}
pagination={{
current: voucherFilters.page,
pageSize: voucherFilters.pageSize,
total: voucherTotal,
showSizeChanger: true,
onChange: (page, pageSize) => loadVouchers({ page, pageSize }),
}}
/>
</Card>
<Card title="接口操作">
<div className="kuaishou-industry-tool-grid">
<Input
placeholder="卖家 ID"
value={toolForm.sellerId}
onChange={(event) => updateToolForm({ sellerId: event.target.value })}
/>
<Input
placeholder="订单号 oid"
value={toolForm.oid}
onChange={(event) =>
updateToolForm({ oid: event.target.value, orderId: event.target.value })
}
/>
<Input
placeholder="任务 ID"
value={String(toolForm.taskId || '')}
onChange={(event) => updateToolForm({ taskId: event.target.value })}
/>
<Input
placeholder="券码"
value={toolForm.voucherCode}
onChange={(event) => updateToolForm({ voucherCode: event.target.value })}
/>
<Input
placeholder="电子凭证类型"
value={toolForm.eticketType}
onChange={(event) => updateToolForm({ eticketType: event.target.value })}
/>
<Input
placeholder="核销序列号"
value={toolForm.serialNum}
onChange={(event) => updateToolForm({ serialNum: event.target.value })}
/>
<Input
placeholder="核销类型"
value={toolForm.consumeType}
onChange={(event) => updateToolForm({ consumeType: event.target.value })}
/>
<Input
placeholder="冲正原因"
value={toolForm.reason}
onChange={(event) => updateToolForm({ reason: event.target.value })}
/>
<Input
placeholder="门店名称"
value={toolForm.storeName}
onChange={(event) => updateToolForm({ storeName: event.target.value })}
/>
<Input
placeholder="门店地址"
value={toolForm.storeAddress}
onChange={(event) => updateToolForm({ storeAddress: event.target.value })}
/>
</div>
<Space wrap className="kuaishou-industry-actions">
<Button
icon={<SyncOutlined />}
loading={actionLoading === 'check'}
onClick={runCheckAvailable}
>
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
loading={actionLoading === 'consume'}
onClick={runConsume}
>
</Button>
<Button
danger
icon={<RollbackOutlined />}
loading={actionLoading === 'reverse'}
onClick={runReverse}
>
</Button>
<Button
icon={<SendOutlined />}
loading={actionLoading === 'resend'}
onClick={runResendCode}
>
</Button>
</Space>
</Card>
</div>
</div>
)
}
function renderRefundTab() {
return (
<div className="page-stack">
<Card title="售后单列表">
<Space wrap className="filter-form">
<Input
placeholder="卖家 ID"
value={refundListForm.sellerId}
onChange={(event) => updateRefundListForm({ sellerId: event.target.value })}
/>
<Input
placeholder="订单号"
value={refundListForm.orderId}
onChange={(event) => updateRefundListForm({ orderId: event.target.value })}
/>
<DatePicker.RangePicker
showTime
value={refundDateRange}
onChange={(value) => setRefundDateRange(value)}
/>
<Select
style={{ width: 140 }}
value={refundListForm.type}
options={[
{ label: '等待退款', value: '8' },
{ label: '全部退款', value: '9' },
]}
onChange={(value) => updateRefundListForm({ type: value })}
/>
<Input placeholder="状态" value={refundListForm.status} onChange={(event) => updateRefundListForm({ status: event.target.value })} />
<Input placeholder="游标" value={refundListForm.pcursor} onChange={(event) => updateRefundListForm({ pcursor: event.target.value })} />
<InputNumber
min={1}
max={100}
value={Number(refundListForm.pageSize)}
onChange={(value) => updateRefundListForm({ pageSize: String(value || 50) })}
/>
<Button
type="primary"
icon={<SearchOutlined />}
loading={actionLoading === 'refund-list'}
onClick={runRefundList}
>
</Button>
</Space>
<Table
rowKey={(row) => String(row.refundId || row.oid || JSON.stringify(row))}
columns={refundColumns}
dataSource={refundRows}
scroll={{ x: 980 }}
pagination={{ pageSize: 10 }}
style={{ marginTop: 16 }}
/>
</Card>
<div className="kuaishou-industry-split">
<Card title="同意退款">
<div className="kuaishou-industry-tool-grid">
<Input
placeholder="卖家 ID"
value={approveForm.sellerId || refundListForm.sellerId}
onChange={(event) => setApproveForm({ ...approveForm, sellerId: event.target.value })}
/>
<Input
placeholder="退款单编号"
value={String(approveForm.refundId || '')}
onChange={(event) => setApproveForm({ ...approveForm, refundId: event.target.value })}
/>
<Input
placeholder="退款金额(分)"
value={String(approveForm.refundAmount || '')}
onChange={(event) =>
setApproveForm({ ...approveForm, refundAmount: event.target.value })
}
/>
<Input
placeholder="退款单状态"
value={String(approveForm.status || '')}
onChange={(event) => setApproveForm({ ...approveForm, status: event.target.value })}
/>
<Input
placeholder="协商状态"
value={String(approveForm.negotiateStatus || '')}
onChange={(event) =>
setApproveForm({ ...approveForm, negotiateStatus: event.target.value })
}
/>
<Input
placeholder="退款方式"
value={String(approveForm.refundHandingWay || '')}
onChange={(event) =>
setApproveForm({ ...approveForm, refundHandingWay: event.target.value })
}
/>
<Input
placeholder="说明"
value={approveForm.desc}
onChange={(event) => setApproveForm({ ...approveForm, desc: event.target.value })}
/>
</div>
<Button
type="primary"
icon={<CheckCircleOutlined />}
loading={actionLoading === 'refund-approve'}
onClick={runApproveRefund}
>
退
</Button>
</Card>
<Card title="不同意退款">
<div className="kuaishou-industry-tool-grid">
<Input
placeholder="卖家 ID"
value={disagreeForm.sellerId || refundListForm.sellerId}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, sellerId: event.target.value })
}
/>
<Input
placeholder="退款单编号"
value={String(disagreeForm.refundId || '')}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, refundId: event.target.value })
}
/>
<Input
placeholder="拒绝原因枚举"
value={String(disagreeForm.sellerDisagreeReason || '')}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, sellerDisagreeReason: event.target.value })
}
/>
<Input
placeholder="拒绝说明"
value={disagreeForm.sellerDisagreeDesc}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, sellerDisagreeDesc: event.target.value })
}
/>
<Input
placeholder="退款单状态"
value={String(disagreeForm.status || '')}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, status: event.target.value })
}
/>
<Input
placeholder="协商状态"
value={String(disagreeForm.negotiateStatus || '')}
onChange={(event) =>
setDisagreeForm({ ...disagreeForm, negotiateStatus: event.target.value })
}
/>
</div>
<Button
danger
icon={<RollbackOutlined />}
loading={actionLoading === 'refund-disagree'}
onClick={runDisagreeRefund}
>
退
</Button>
</Card>
</div>
</div>
)
}
function renderResultTab() {
return (
<Card
title={lastResultTitle}
extra={
<Button icon={<ReloadOutlined />} onClick={() => setLastResult(null)}>
</Button>
}
>
{lastResult ? (
<pre className="json-preview">{stringifyDisplayJson(lastResult)}</pre>
) : (
<Alert type="info" showIcon message="暂无接口结果" />
)}
</Card>
)
}
function updateToolForm(patch: Partial<AdminKuaishouIndustryVoucherToolPayload>) {
setToolForm((current) => ({ ...current, ...patch }))
}
function updateRefundListForm(patch: Partial<RefundListState>) {
setRefundListForm((current) => ({ ...current, ...patch }))
}
}
function extractRefundRows(response: Record<string, unknown> | null): Array<Record<string, unknown>> {
const data = response?.data
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return []
}
const rows = (data as { refundOrderInfoList?: unknown }).refundOrderInfoList
return Array.isArray(rows)
? rows.filter((item): item is Record<string, unknown> =>
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
)
: []
}
function getVoucherStatusColor(status: string) {
const normalized = String(status || '').toUpperCase()
if (normalized === 'CONSUMED') return 'green'
if (normalized === 'DESTROYED') return 'red'
return 'blue'
}
function getVoucherStatusLabel(status: string) {
const normalized = String(status || '').toUpperCase()
if (normalized === 'CONSUMED') return '已核销'
if (normalized === 'DESTROYED') return '已销毁'
if (normalized === 'UNUSED') return '未使用'
return status || '-'
}
function formatTimestamp(value: unknown) {
const timestamp = Number(value || 0)
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '-'
}
return formatAdminDateTime(new Date(timestamp).toISOString())
}
@@ -3,5 +3,6 @@ export * from './dashboard'
export * from './users'
export * from './audit-logs'
export * from './platform-config'
export * from './kuaishou-industry'
export * from './orders'
export * from './tasks'
@@ -0,0 +1,81 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminKuaishouIndustryOpenApiResult,
AdminKuaishouIndustryRefundApprovePayload,
AdminKuaishouIndustryRefundDisagreePayload,
AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherListResult,
AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryVoucherToolPayload,
} from '@/types/admin'
export function fetchAdminKuaishouIndustryVouchers(params?: Record<string, unknown>) {
return apiGet<AdminKuaishouIndustryVoucherListResult>(
'/api/v1/admin/kuaishou-industry/vouchers',
params,
)
}
export function listAdminKuaishouIndustryRefunds(
payload: AdminKuaishouIndustryRefundListPayload,
) {
return apiPost<AdminKuaishouIndustryOpenApiResult>(
'/api/v1/admin/kuaishou-industry/refunds/list',
payload,
)
}
export function approveAdminKuaishouIndustryRefund(
payload: AdminKuaishouIndustryRefundApprovePayload,
) {
return apiPost<AdminKuaishouIndustryOpenApiResult>(
'/api/v1/admin/kuaishou-industry/refunds/approve',
payload,
)
}
export function disagreeAdminKuaishouIndustryRefund(
payload: AdminKuaishouIndustryRefundDisagreePayload,
) {
return apiPost<AdminKuaishouIndustryOpenApiResult>(
'/api/v1/admin/kuaishou-industry/refunds/disagree',
payload,
)
}
export function checkAdminKuaishouIndustryVoucherAvailable(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
return apiPost<AdminKuaishouIndustryOpenApiResult>(
'/api/v1/admin/kuaishou-industry/vouchers/check-available',
payload,
)
}
export function reverseAdminKuaishouIndustryVoucher(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
return apiPost<AdminKuaishouIndustryOpenApiResult>(
'/api/v1/admin/kuaishou-industry/vouchers/reverse',
payload,
)
}
export function consumeAdminKuaishouIndustryVoucher(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
return apiPost<AdminKuaishouIndustryVoucherConsumeResult>(
'/api/v1/admin/kuaishou-industry/vouchers/consume',
payload,
)
}
export function resendAdminKuaishouIndustryVoucherCode(
payload: AdminKuaishouIndustryVoucherToolPayload,
) {
return apiPost<AdminKuaishouIndustryVoucherResendResult>(
'/api/v1/admin/kuaishou-industry/vouchers/resend-code',
payload,
)
}
+22
View File
@@ -306,6 +306,24 @@ select {
line-height: 1.6;
}
.kuaishou-industry-split {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(360px, 0.75fr);
gap: 16px;
align-items: start;
}
.kuaishou-industry-tool-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.kuaishou-industry-actions {
margin-top: 2px;
}
.task-action-hint {
display: block;
margin-top: 10px;
@@ -1083,6 +1101,10 @@ select {
grid-template-columns: 1fr;
}
.kuaishou-industry-split {
grid-template-columns: 1fr;
}
.manual-dispatch-grid,
.fulfillment-overview-grid,
.task-flow-grid {
+12
View File
@@ -28,6 +28,18 @@ export type {
AdminTaskDetail,
} from './tasks'
export type {
AdminKuaishouIndustryOpenApiResult,
AdminKuaishouIndustryVoucher,
AdminKuaishouIndustryVoucherListResult,
AdminKuaishouIndustryVoucherConsumeResult,
AdminKuaishouIndustryVoucherResendResult,
AdminKuaishouIndustryRefundListPayload,
AdminKuaishouIndustryRefundApprovePayload,
AdminKuaishouIndustryRefundDisagreePayload,
AdminKuaishouIndustryVoucherToolPayload,
} from './kuaishou-industry'
// Platform config types
export type {
AdminNotificationBarkRecipient,
@@ -0,0 +1,114 @@
import type { AdminPagination } from './common'
export interface AdminKuaishouIndustryOpenApiResult {
success: boolean
response: Record<string, unknown> | null
error: string
request: Record<string, unknown> | null
durationMs: number
httpStatus: number
skippedReason: string
voucher?: AdminKuaishouIndustryVoucher | null
}
export interface AdminKuaishouIndustryVoucher {
id: number
voucherCode: string
oid: string
orderId: number | null
taskId: number | null
unitIndex: number
sellerId: string
tokenMasked: string
status: string
validStartTime: number
validEndTime: number
consumeSerialNum: string
consumeDetails: Array<Record<string, unknown>>
consumedAt: string | null
destroyedAt: string | null
sendCallbackStatus: string
sendCallbackAttemptCount: number
sendCallbackLastError: string
sendCallbackSentAt: string | null
rawPayload: Record<string, unknown>
createdAt: string
updatedAt: string
}
export interface AdminKuaishouIndustryVoucherListResult {
items: AdminKuaishouIndustryVoucher[]
pagination: AdminPagination
}
export interface AdminKuaishouIndustryVoucherConsumeResult {
success: boolean
voucher: AdminKuaishouIndustryVoucher
task: null | {
taskId: number
taskNo: string
status: string
deliveryStatus: string
}
}
export interface AdminKuaishouIndustryVoucherResendResult {
success: boolean
response: Record<string, unknown> | null
error: string
voucher: AdminKuaishouIndustryVoucher | null
}
export interface AdminKuaishouIndustryRefundListPayload {
sellerId?: string
beginTime?: number | string
endTime?: number | string
type?: number | string
pageSize?: number | string
currentPage?: number | string
sort?: number | string
queryType?: number | string
negotiateStatus?: number | string
pcursor?: string
status?: number | string
orderId?: string
}
export interface AdminKuaishouIndustryRefundApprovePayload {
sellerId?: string
refundId?: number | string
desc?: string
refundAmount?: number | string
status?: number | string
negotiateStatus?: number | string
refundHandingWay?: number | string
}
export interface AdminKuaishouIndustryRefundDisagreePayload {
sellerId?: string
refundId?: number | string
sellerDisagreeReason?: number | string
sellerDisagreeDesc?: string
sellerDisagreeImages?: string[]
status?: number | string
negotiateStatus?: number | string
}
export interface AdminKuaishouIndustryVoucherToolPayload {
sellerId?: string
buyerId?: string
orderId?: string
oid?: string
taskId?: number | string
voucherCode?: string
eticketType?: string
consumeType?: string
serialNum?: string
reason?: string
token?: string
storeName?: string
storeAddress?: string
expressCode?: string
expressNo?: string
etickets?: Array<{ id?: string; code?: string; num?: number | string }>
}