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

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`
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}`, {
url,
...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(),
},
})
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,
})
if (result.skippedReason === 'disabled') {
logInfo('[kuaishou-industry/consume-callback]', '行业电子凭证配置未启用,跳过核销回调')
return { success: true }
}
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))
if (result.skippedReason === 'token_error') {
const message = result.error || 'accessToken 刷新失败'
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', {
error: message,
})
return { success: false, error: message }
}
if (result.skippedReason === 'missing_access_token') {
const message = 'accessToken 未配置,无法发起核销回调'
logWarn('[kuaishou-industry/consume-callback]', message)
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()
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,
})
}
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 {
success: result.success,
...(result.response ? { response: result.response } : {}),
...(result.error ? { error: result.error } : {}),
}
}
return detail
}
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -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`
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}`, {
url,
...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(),
},
})
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,
})
if (result.skippedReason === 'disabled') {
logInfo('[kuaishou-industry/destroy-callback]', '行业电子凭证配置未启用,跳过销毁回调')
return { success: true }
}
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))
if (result.skippedReason === 'token_error') {
const message = result.error || 'accessToken 刷新失败'
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', {
error: message,
})
return { success: false, error: message }
}
if (result.skippedReason === 'missing_access_token') {
const message = 'accessToken 未配置,无法发起销毁回调'
logWarn('[kuaishou-industry/destroy-callback]', message)
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()
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,
})
}
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 {
success: result.success,
...(result.response ? { response: result.response } : {}),
...(result.error ? { error: result.error } : {}),
}
}
return detail
}
function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
}
@@ -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 signParams: JsonObject = {
method: 'integration.callback.virtual.eticket.send',
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/send`
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 || '',
...buildSendCallbackRequestLog({
url,
signParams,
bizParams,
}),
...request,
}
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' })
if (result.skippedReason === 'disabled') {
logIntegration('[kuaishou-industry/send-callback]', '行业电子凭证配置未启用,跳过发货回调')
return { success: true }
}
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 }
}
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' })
}
function buildSendCallbackRequestLog({
url,
signParams,
bizParams,
}: {
url: string
signParams: JsonObject
bizParams: JsonObject
}): JsonObject {
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 }>
}
@@ -0,0 +1,48 @@
kwaishop_aftersales_addRefund
需商家授权
售后单新增消息
更新时间: 2024-04-22 14:25:16
有新增售后单时触发消息发送
系统参数
参数名 类型 详情描述
eventId String 消息唯一id
msgId String 业务消息内容唯一id
bizId Long 业务id如订单id、退款单id、商品id
userId Long 授权用户id
openId String 授权用户openId
appKey String 应用id
event String 消息标示
info String 消息内容,业务内容Json串,详见消息文档参数
status Integer 状态 0未知 1发送中 2发送成功 3发送失败
createTime Long 创建时间
updateTime Long 更新时间
业务参数
参数名 类型 示例值 详情描述
orderId Long 2015612346452419 订单id
refundId Long 32142132 售后单id
handlingWay Integer 3 退款方式,枚举: [1, "退货退款"] [10, "仅退款"] [3, "换货"][4, "补寄"][5, "维修"]
specialRefundType Integer 3 特殊退款类型[0, "非特殊退款"] [1, "价保"]
status Integer 3 订单退款状态[10, "买家已经申请退款,等待卖家同意"] [12, "卖家已拒绝,等待买家处理"] [20, "协商纠纷,等待平台处理"] [30, "卖家已经同意退款,等待买家退货"] [40, "买家已经退货,等待卖家确认收货"] [45, "卖家已经发货,等待买家确认收货"] [50, "卖家已经同意退款,等待系统执行退款"] [60, "退款成功"] [70, "退款关闭"]
createTime Long 1679035606000 售后单创建时间
消息示例
JSON
{
"eventId": "237_6341_60079475",
"msgId": "470_60079469",
"bizId": 23666666666666,
"userId": 2066666666,
"openId": "f19666666666666666666666666666666",
"appKey": "ks66666666666666666",
"event": "kwaishop_aftersales_addRefund",
"info": {
"orderId": 2015612346452419,
"refundId": 32142132,
"handlingWay": 3,
"specialRefundType": 3,
"status": 3,
"createTime": 1679035606000
},
"status": 2,
"createTime": 1691563032872,
"updateTime": 1691567756044
}
@@ -0,0 +1,143 @@
open.seller.order.refund.pcursor.list
需商家授权
GET
获取售后单列表
更新时间: 2024-04-22 16:28:28
查询商家售后单列表(游标方式),可根据订单id查询关联的全量售后单列表,handlingway为售后方式,status为售后状态
系统环境
环境 域名
线上环境(推荐) https://openapi.kwaixiaodian.com
线上环境(备用) https://open.kwaixiaodian.com
系统参数
参数名 类型 必须 详情描述
appkey String 是 平台分配的appId
timestamp Number 是 发起请求的Unix时间戳,单位为毫秒
access_token String 是 访问token,所有需用户授权API使用code模式获取,不需要用户授权API使用client_credentials获取,详情参考《授权说明》文档
version Number 是 请求的API版本号,目前版本为1
param JSON 是 业务参数,详见下方的请求入参
method String 是 请求的API英文名,详见各API定义
sign String 是 API入参的签名结果参数
signMethod String 是 签名算法,支持HMAC_SHA256和MD5,推荐使用HMAC_SHA256
请求参数
参数名 类型 必须 示例值 详情描述
beginTime Long 是 1543817629000 订单生成的开始时间(单位毫秒),在当前时间的近90天内,不能大于90天,且小于截止时间
endTime Long 是 1543817629000 订单生成的截止时间(单位毫秒)在当前时间的近90天内,不能大于90天,且大于开始时间,且与开始时间的时间范围不大于1天 (与开始时间的时间范围建议做成随时可配置,该范围可能在活动期间随时变化,比如变成小时级或者分钟级)。
type Integer 是 8 退款单请求类型,8 等待退款 9 全部退款订单
pageSize Integer 是 50 每次请求数量,最多一页100条
currentPage Long 是 1 当前页码
sort Integer 否 1 排序方式,1时间降序 2时间升序 ,默认降序
queryType Integer 否 1 查找方式,1按创建时间查找 2按更新时间查找 ,默认创建时间
negotiateStatus Integer 否 1 协商状态,1待商家处理 2 商家同意 3商家驳回,等待买家修改 默认返回所有数据
pcursor String 是 1543843735000_1 游标内容,第一次传空串,之后传上一次的pcursor返回值,若返回“nomore”则标识到底
status Integer 否 10 退款状态,枚举:[10, "买家已经申请退款,等待卖家同意"] [12, "卖家已拒绝,等待买家处理"] [20, "协商纠纷,等待平台处理"] [30, "卖家已经同意退款,等待买家退货"] [40, "买家已经退货,等待卖家确认收货"] [45, "卖家已经发货,等待买家确认收货"] [50, "卖家已经同意退款,等待系统执行退款"] [60, "退款成功"] [70, "退款关闭"]
option RefundPageOption 否 {} 选项
orderId Long 否 2412314120000 订单id,可根据订单id查询关联的所有售后单列表
返回参数
参数名 类型 示例值 详情描述
code String 1 主返回码
msg String success 主返回信息
sub_code String 1 子返回码
sub_msg String SUCCESS 子返回信息
result Integer 1 返回码,1正确,其他返回码表示接口未执行成功
data MerchantRefundListDataView {} 售后单列表信息
error_msg String SUCCESS 返回码描述
错误码
错误码 错误类型 错误描述 解决方法
13 参数非法 参数不合法 分页参数不符合‘nomore’ 或 ‘数字_数字’的格式,请检查
16 系统错误 请求过于频繁 请求过于频繁,请稍后重试
51 参数非法 pageSize 应为20 pageSize超过了100
53 参数非法 page number 过大 page number 过大,分页参数为nomore报错
300002 业务错误 退款单类型错误 退款单类型错误
300003 业务错误 结束时间必须大于开始时间 结束时间必须大于开始时间
300005 业务错误 开始时间不能大于90天前 开始时间不能大于90天前
300019 业务错误 时间范围超限 时间范围超限
300107 业务错误 状态无效 status参数不在RefundStatusEnum里
300109 业务错误 退款单请求类型为等待退款时,退款单状态列表中不能包含非等待退款的状态 退款单请求类型为等待退款时,退款单状态列表中不能包含非等待退款的状态
请求示例
JAVA
CURL
package com.kuaishou.merchant.open.api.sdk.demo;
import com.kuaishou.merchant.open.api.common.utils.GsonUtils;
import com.kuaishou.merchant.open.api.request.refund.OpenSellerOrderRefundPcursorListRequest;
import com.kuaishou.merchant.open.api.response.refund.OpenSellerOrderRefundPcursorListResponse;
import com.kuaishou.merchant.open.api.client.AccessTokenKsMerchantClient;
public class AccessTokenKsMerchantClientDemo {
public static void main(String[] args) {
String url = "https://openapi.kwaixiaodian.com";
String appKey = "your appKey";
String signSecret = "your app signSecret";
String accessToken = "your accessToken";
AccessTokenKsMerchantClient client = new AccessTokenKsMerchantClient(url,appKey,signSecret);
OpenSellerOrderRefundPcursorListRequest request = new OpenSellerOrderRefundPcursorListRequest();
request.setAccessToken(accessToken);
request.setApiMethodVersion(1L);
request.setBeginTime(1543817629000);
request.setEndTime(1543817629000);
request.setType(8);
request.setPageSize(50);
request.setCurrentPage(1);
request.setSort(1);
request.setQueryType(1);
request.setNegotiateStatus(1);
request.setPcursor("1543843735000_1");
request.setStatus(10);
RefundPageOption obj1 = new RefundPageOption();
obj1.setNeedExchange(false);
request.setOption(obj1);
request.setOrderId(2412314120000);
OpenSellerOrderRefundPcursorListResponse response = client.execute(request);
System.out.println(GsonUtils.toJSON(response));
}
}
响应示例
JSON
{
"result": 1,
"data": {
"currentPage": 1,
"pageSize": 100,
"totalPage": 10,
"totalSize": 100,
"beginTime": 1597248000000,
"endTime": 1597276800000,
"pcursor": "nomore",
"refundOrderInfoList": [
{
"oid": 12020384,
"refundId": 2016300011212478,
"handlingWay": 1,
"negotiateStatus": 1,
"refundFee": 890,
"skuId": 92381223,
"refundReason": 1,
"status": 10,
"buyerId": 63523209,
"refundType": 1298723,
"sellerId": 92371012,
"refundDesc": "描述",
"submitTime": 1543843735000,
"relItemId": 111,
"negotiateUpdateTime": 1543843735000,
"updateTime": 1543843735000,
"createTime": 1543843735000,
"relSkuId": 120823876,
"skuNick": "111",
"logisticsId": 1234567,
"endTime": 1543843735000,
"itemId": 123456,
"receiptStatus": 1,
"refundReasonDesc": "拍多、拍错",
"expireTime": 1543843735000
}
]
},
"error_msg": "SUCCESS"
}
@@ -0,0 +1,91 @@
open.seller.order.refund.approve
需商家授权
POST
商家同意退款
更新时间: 2023-11-01 14:45:53
1.售后单类型为仅退款时,调用该API完成退款操作
2.售后单类型为退货退款时,使用open.seller.order.refund.returngoods.approve(商家同意退货API)完成同意退货操作
系统环境
环境 域名
线上环境(推荐) https://openapi.kwaixiaodian.com
线上环境(备用) https://open.kwaixiaodian.com
系统参数
参数名 类型 必须 详情描述
appkey String 是 平台分配的appId
timestamp Number 是 发起请求的Unix时间戳,单位为毫秒
access_token String 是 访问token,所有需用户授权API使用code模式获取,不需要用户授权API使用client_credentials获取,详情参考《授权说明》文档
version Number 是 请求的API版本号,目前版本为1
param JSON 是 业务参数,详见下方的请求入参
method String 是 请求的API英文名,详见各API定义
sign String 是 API入参的签名结果参数
signMethod String 是 签名算法,支持HMAC_SHA256和MD5,推荐使用HMAC_SHA256
请求参数
参数名 类型 必须 示例值 详情描述
refundId Long 是 129230087 退款单编号
desc String 否 说明 退款说明(预计4月中旬下线)
refundAmount Long 是 1000 退款金额 单位:分
status Integer 否 1 退款单当前状态
negotiateStatus Integer 否 1 协商状态,枚举: [1, "待商家处理"] [2, "商家同意"] [3, "商家驳回,等待买家修改"]
refundHandingWay Integer 否 1 退款方式,枚举:[0, "未知"] [1, "退货退款"] [10,"仅退款"]
返回参数
参数名 类型 示例值 详情描述
code String 1 主返回码
msg String success 主返回信息
sub_code String 1 子返回码
sub_msg String SUCCESS 子返回信息
result Integer 1 返回码,1正确,其他返回码表示接口未执行成功
error_msg String SUCCESS 返回码描述
错误码
错误码 错误类型 错误描述 解决方法
16 系统错误 请求过于频繁 请求过于频繁,请稍后重试
21 业务错误 当前退款单状态已更新 当前退款单状态已更新,请刷新后重试
1004 系统错误 服务繁忙 服务繁忙,请稍后重试
3616 业务错误 当前退款单状态已更新 当前退款单状态已更新,请刷新后重试
6002 业务错误 保证金余额不足 保证金余额不足,请检查
300009 业务错误 退款金额为0 退款金额为0,请检查
300011 业务错误 退款单不存在 退款单不存在,请检查
300013 业务错误 买家取消退款 买家取消退款
300014 业务错误 退款单状态已更新 退款单状态已更新,请刷新重试
300108 业务错误 输入的退款金额与退款单实际金额不匹配 输入的退款金额与退款单实际金额不匹配,请检查
请求示例
JAVA
CURL
package com.kuaishou.merchant.open.api.sdk.demo;
import com.kuaishou.merchant.open.api.common.utils.GsonUtils;
import com.kuaishou.merchant.open.api.request.refund.OpenSellerOrderRefundApproveRequest;
import com.kuaishou.merchant.open.api.response.refund.OpenSellerOrderRefundApproveResponse;
import com.kuaishou.merchant.open.api.client.AccessTokenKsMerchantClient;
public class AccessTokenKsMerchantClientDemo {
public static void main(String[] args) {
String url = "https://openapi.kwaixiaodian.com";
String appKey = "your appKey";
String signSecret = "your app signSecret";
String accessToken = "your accessToken";
AccessTokenKsMerchantClient client = new AccessTokenKsMerchantClient(url,appKey,signSecret);
OpenSellerOrderRefundApproveRequest request = new OpenSellerOrderRefundApproveRequest();
request.setAccessToken(accessToken);
request.setApiMethodVersion(1L);
request.setRefundId(129230087);
request.setDesc("说明");
request.setRefundAmount(1000);
request.setStatus(1);
request.setNegotiateStatus(1);
request.setRefundHandingWay(1);
OpenSellerOrderRefundApproveResponse response = client.execute(request);
System.out.println(GsonUtils.toJSON(response));
}
}
响应示例
JSON
{
"result": 1,
"error_msg": "SUCCESS"
}
@@ -0,0 +1,83 @@
open.seller.order.refund.disagree.refund
需商家授权
POST
商家不同意退款
更新时间: 2021-12-21 11:35:17
商家不同意退款
系统环境
环境 域名
线上环境(推荐) https://openapi.kwaixiaodian.com
线上环境(备用) https://open.kwaixiaodian.com
系统参数
参数名 类型 必须 详情描述
appkey String 是 平台分配的appId
timestamp Number 是 发起请求的Unix时间戳,单位为毫秒
access_token String 是 访问token,所有需用户授权API使用code模式获取,不需要用户授权API使用client_credentials获取,详情参考《授权说明》文档
version Number 是 请求的API版本号,目前版本为1
param JSON 是 业务参数,详见下方的请求入参
method String 是 请求的API英文名,详见各API定义
sign String 是 API入参的签名结果参数
signMethod String 是 签名算法,支持HMAC_SHA256和MD5,推荐使用HMAC_SHA256
请求参数
参数名 类型 必须 示例值 详情描述
refundId Long 是 129230087 退款单id
sellerDisagreeReason Integer 是 1 商家拒绝原因,枚举:[1 商品退回后才能退款] [2 家已签收] [3 买家未举证/举证无效] [4 已发货,请买家承担运费] [5 已履行约定"] [100 其他]
sellerDisagreeDesc String 是 不同意 商家拒绝原因说明
sellerDisagreeImages List<String> 否 [] 拒绝图片,最多6张
status Integer 是 10 退款单当前状态,枚举:[10, "买家仅退款申请"] [11, "买家退货退款申请"] [20, "平台介入-买家仅退款申请"] [21, "平台介入-买家退货退款申请"] [22, "平台介入-已确认退货退款"] [30, "商品回寄信息待买家更新"] [40, "商品回寄信息待卖家确认"] [50, "退款执行中"] [60, "退款成功"] [70, "退款失败"]
negotiateStatus Integer 是 1 协商状态(以退款单信息里返回值为入参),枚举:[1,"待商家处理"] [3, "商家驳回,等待买家修改"]
返回参数
参数名 类型 示例值 详情描述
code String 1 主返回码
msg String success 主返回信息
sub_code String 1 子返回码
sub_msg String SUCCESS 子返回信息
result Integer 1 返回码,1正确,其他返回码表示接口未执行成功
error_msg String SUCCESS 返回码描述
错误码
错误码 错误类型 错误描述 解决方法
暂无数据
请求示例
JAVA
CURL
package com.kuaishou.merchant.open.api.sdk.demo;
import com.kuaishou.merchant.open.api.common.utils.GsonUtils;
import com.kuaishou.merchant.open.api.request.refund.OpenSellerOrderRefundDisagreeRefundRequest;
import com.kuaishou.merchant.open.api.response.refund.OpenSellerOrderRefundDisagreeRefundResponse;
import com.kuaishou.merchant.open.api.client.AccessTokenKsMerchantClient;
public class AccessTokenKsMerchantClientDemo {
public static void main(String[] args) {
String url = "https://openapi.kwaixiaodian.com";
String appKey = "your appKey";
String signSecret = "your app signSecret";
String accessToken = "your accessToken";
AccessTokenKsMerchantClient client = new AccessTokenKsMerchantClient(url,appKey,signSecret);
OpenSellerOrderRefundDisagreeRefundRequest request = new OpenSellerOrderRefundDisagreeRefundRequest();
request.setAccessToken(accessToken);
request.setApiMethodVersion(1L);
request.setRefundId(129230087);
request.setSellerDisagreeReason(1);
request.setSellerDisagreeDesc("不同意");
List<String> list1 = new ArrayList<>();
list1.add("");
request.setSellerDisagreeImages(list1);
request.setStatus(10);
request.setNegotiateStatus(1);
OpenSellerOrderRefundDisagreeRefundResponse response = client.execute(request);
System.out.println(GsonUtils.toJSON(response));
}
}
响应示例
JSON
{
"result": 1,
"error_msg": "SUCCESS"
}
@@ -0,0 +1,92 @@
integration.callback.virtual.eticket.reverse
需用户授权
POST
电子凭证冲正回调接口
更新时间: 2022-05-11 16:25:25
电子凭证冲正回调,后续再开放主动通知冲正接口
系统环境
环境 域名
线上环境(推荐) https://openapi.kwaixiaodian.com
线上环境(备用) https://open.kwaixiaodian.com
系统参数
参数名 类型 必须 详情描述
appkey String 是 平台分配的appId
timestamp Number 是 发起请求的Unix时间戳,单位为毫秒
access_token String 是 访问token,所有需用户授权API使用code模式获取,不需要用户授权API使用client_credentials获取,详情参考《授权说明》文档
version Number 是 请求的API版本号,目前版本为1
param JSON 是 业务参数,详见下方的请求入参
method String 是 请求的API英文名,详见各API定义
sign String 是 API入参的签名结果参数
signMethod String 是 签名算法,支持HMAC_SHA256和MD5,推荐使用HMAC_SHA256
请求参数
参数名 类型 必须 示例值 详情描述
oid String 是 21111 订单id
eticketType String 否 DINING_OPEN_TICKET/DEFAULT_MEDICAL_TICKET 电子凭证类型,跟商家想要入驻的类目相关联,非必传,默认DINING_OPEN_TICKET
etickets List<ReverseETicket> 是 [] 冲正的卡券列表
serialNum String 否 fffassaass 核销时的序列号,冲正时必填!
reason String 否 消费者投诉 冲正原因
ext String 否 {} 扩展信息
token String 否 fasddda 鉴权Token,订单操作维度
返回参数
参数名 类型 示例值 详情描述
code String 1 主返回码
msg String success 主返回信息
sub_code String 1 子返回码
sub_msg String "success" 子返回信息
commonResult KwaishopPlatformDigitalBaseRespInfo {} 返回码,1成功
result Integer 1 返回码,1成功
error_msg String "success" 错误信息
错误码
错误码 错误类型 错误描述 解决方法
暂无数据
请求示例
JAVA
CURL
package com.kuaishou.merchant.open.api.sdk.demo;
import com.kuaishou.merchant.open.api.common.utils.GsonUtils;
import com.kuaishou.merchant.open.api.request.virtual.IntegrationCallbackVirtualEticketReverseRequest;
import com.kuaishou.merchant.open.api.response.virtual.IntegrationCallbackVirtualEticketReverseResponse;
import com.kuaishou.merchant.open.api.client.AccessTokenKsMerchantClient;
public class AccessTokenKsMerchantClientDemo {
public static void main(String[] args) {
String url = "https://openapi.kwaixiaodian.com";
String appKey = "your appKey";
String signSecret = "your app signSecret";
String accessToken = "your accessToken";
AccessTokenKsMerchantClient client = new AccessTokenKsMerchantClient(url,appKey,signSecret);
IntegrationCallbackVirtualEticketReverseRequest request = new IntegrationCallbackVirtualEticketReverseRequest();
request.setAccessToken(accessToken);
request.setApiMethodVersion(1L);
request.setOid("21111");
request.setEticketType("KFC");
List<ReverseETicket> list1 = new ArrayList<>();
ReverseETicket obj1 = new ReverseETicket();
obj1.setId("123");
list1.add(obj1);
request.setEtickets(list1);
request.setSerialNum("fffassaass");
request.setReason("消费者投诉");
request.setExt("{}");
request.setToken("fasddda");
IntegrationCallbackVirtualEticketReverseResponse response = client.execute(request);
System.out.println(GsonUtils.toJSON(response));
}
}
响应示例
JSON
{
"commonResult": {
"code": 1,
"message": "success"
},
"result": 1,
"error_msg": "\"success\""
}
@@ -0,0 +1,92 @@
open.virtual.eticket.checkavailable
需用户授权
POST
查询电子凭证校验结果
更新时间: 2023-11-01 20:02:42
检查电子凭证是否有效
系统环境
环境 域名
线上环境(推荐) https://openapi.kwaixiaodian.com
线上环境(备用) https://open.kwaixiaodian.com
系统参数
参数名 类型 必须 详情描述
appkey String 是 平台分配的appId
timestamp Number 是 发起请求的Unix时间戳,单位为毫秒
access_token String 是 访问token,所有需用户授权API使用code模式获取,不需要用户授权API使用client_credentials获取,详情参考《授权说明》文档
version Number 是 请求的API版本号,目前版本为1
param JSON 是 业务参数,详见下方的请求入参
method String 是 请求的API英文名,详见各API定义
sign String 是 API入参的签名结果参数
signMethod String 是 签名算法,支持HMAC_SHA256和MD5,推荐使用HMAC_SHA256
请求参数
参数名 类型 必须 示例值 详情描述
buyerId Long 否 211 买家编号
eticketType String 是 22 电子凭证类型
etickets List<AvailableEticket> 是 [] 电子凭证列表
orderId Long 否 1 订单编号
sellerId Long 是 211 卖家编号
返回参数
参数名 类型 示例值 详情描述
code String 1 主返回码
msg String success 主返回信息
sub_code String 1 子返回码
sub_msg String 1 子返回信息
etickets List<AvailableEticketDetail> [] 电子凭证校验结果
result Integer 1 是否成功
error_msg String 1 错误信息
错误码
错误码 错误类型 错误描述 解决方法
3009 业务错误 电子凭证核销时,可用数量和平台不一致,其中一部分券不可用 电子凭证核销时,可用数量和平台不一致,其中一部分券不可用
请求示例
JAVA
CURL
package com.kuaishou.merchant.open.api.sdk.demo;
import com.kuaishou.merchant.open.api.common.utils.GsonUtils;
import com.kuaishou.merchant.open.api.request.industry.OpenVirtualEticketCheckavailableRequest;
import com.kuaishou.merchant.open.api.response.industry.OpenVirtualEticketCheckavailableResponse;
import com.kuaishou.merchant.open.api.client.AccessTokenKsMerchantClient;
public class AccessTokenKsMerchantClientDemo {
public static void main(String[] args) {
String url = "https://gw-merchant-staging.test.gifshow.com";
String appKey = "your appKey";
String signSecret = "your app signSecret";
String accessToken = "your accessToken";
AccessTokenKsMerchantClient client = new AccessTokenKsMerchantClient(url,appKey,signSecret);
OpenVirtualEticketCheckavailableRequest request = new OpenVirtualEticketCheckavailableRequest();
request.setAccessToken(accessToken);
request.setApiMethodVersion(1L);
request.setSellerId(211);
request.setBuyerId(211);
request.setOrderId(112);
request.setBizTypeCode("22");
List<AvailableETicketReq> list1 = new ArrayList<>();
AvailableETicketReq obj1 = new AvailableETicketReq();
obj1.setId("111");
obj1.setCode("112");
obj1.setNum(2);
list1.add(obj1);
request.setETicketList(list1);
OpenVirtualEticketCheckavailableResponse response = client.execute(request);
System.out.println(GsonUtils.toJSON(response));
}
}
响应示例
JSON
{
"result": 1,
"error_msg": "success",
"data": {
"sellerId": 1,
"buyerId": 1,
"orderId": 1,
"eticketType": "2"
}
}