From c61078d3f69ff0cbbca870131b1a6df48568637b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 9 Jul 2026 18:39:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BF=AB=E6=89=8B=E7=94=B5?= =?UTF-8?q?=E5=AD=90=E5=87=AD=E8=AF=81=E5=90=8E=E5=8F=B0=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kuaishou-industry-voucher-repo.ts | 79 ++ apps/backend/src/routes/admin.ts | 2 + .../src/routes/admin/kuaishou-industry.ts | 179 ++++ .../admin/kuaishou-industry-admin-service.ts | 379 ++++++++ .../check-available-service.ts | 72 ++ .../consume-callback-service.ts | 200 ++--- .../platforms/kuaishou-industry/crypto.ts | 2 +- .../destroy-callback-service.ts | 200 ++--- .../kuaishou-industry/openapi-client.ts | 195 ++++ .../kuaishou-industry/openapi-values.ts | 53 ++ .../kuaishou-industry/refund-service.ts | 114 +++ .../reverse-callback-service.ts | 82 ++ .../send-callback-service.ts | 237 ++--- apps/backend/src/types/admin/route-inputs.ts | 14 + apps/backend/src/types/admin/write-inputs.ts | 82 ++ apps/frontend/src/App.tsx | 2 + apps/frontend/src/layouts/AdminLayout.tsx | 3 + .../pages/admin/AdminKuaishouIndustryPage.tsx | 834 ++++++++++++++++++ apps/frontend/src/services/admin/index.ts | 1 + .../src/services/admin/kuaishou-industry.ts | 81 ++ apps/frontend/src/styles/main.css | 22 + apps/frontend/src/types/admin/index.ts | 12 + .../src/types/admin/kuaishou-industry.ts | 114 +++ .../解决方案/相关文档/1.1.新增退款单消息.md | 48 + .../相关文档/1.2.售后单列表(游标方式).md | 143 +++ .../解决方案/相关文档/2.1.商家同意退款接口.md | 91 ++ .../相关文档/2.2.商家不同意退款接口.md | 83 ++ .../相关文档/3.电子凭证冲正回调接口 copy.md | 92 ++ .../4.电子凭证检查电子凭证是否有效.md | 92 ++ 29 files changed, 3034 insertions(+), 474 deletions(-) create mode 100644 apps/backend/src/routes/admin/kuaishou-industry.ts create mode 100644 apps/backend/src/services/admin/kuaishou-industry-admin-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/check-available-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/openapi-client.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/openapi-values.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/refund-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/reverse-callback-service.ts create mode 100644 apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx create mode 100644 apps/frontend/src/services/admin/kuaishou-industry.ts create mode 100644 apps/frontend/src/types/admin/kuaishou-industry.ts create mode 100644 docs/行业电子凭证/解决方案/相关文档/1.1.新增退款单消息.md create mode 100644 docs/行业电子凭证/解决方案/相关文档/1.2.售后单列表(游标方式).md create mode 100644 docs/行业电子凭证/解决方案/相关文档/2.1.商家同意退款接口.md create mode 100644 docs/行业电子凭证/解决方案/相关文档/2.2.商家不同意退款接口.md create mode 100644 docs/行业电子凭证/解决方案/相关文档/3.电子凭证冲正回调接口 copy.md create mode 100644 docs/行业电子凭证/解决方案/相关文档/4.电子凭证检查电子凭证是否有效.md diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts index b2c21558..771527a3 100644 --- a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -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( + ` + 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 = '', diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 26c36483..6b9861f4 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -4,6 +4,7 @@ import authRouter from "./admin/auth.js"; import auditLogsRouter from "./admin/audit-logs.js"; import 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); diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts new file mode 100644 index 00000000..5b851cc9 --- /dev/null +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -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 : {} + const result = data && typeof data === 'object' ? data as Record : {} + + 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 { + return String( + payload.voucherCode || + payload.refundId || + payload.oid || + payload.orderId || + payload.sellerId || + 'kuaishou-industry', + ) +} + +export default router diff --git a/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts new file mode 100644 index 00000000..cc5af94e --- /dev/null +++ b/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts @@ -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 + +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[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 { + 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 { + 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 [] + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/check-available-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/check-available-service.ts new file mode 100644 index 00000000..bfc0ccbb --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/check-available-service.ts @@ -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 }) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts index 2e544977..d2c136ae 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/consume-callback-service.ts @@ -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 - -const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' +import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js' type ConsumeCallbackInput = { oid: string @@ -33,38 +26,9 @@ type ConsumeCallbackInput = { consumePoiId?: number } -export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { - let config = getKuaishouIndustryConfig() - - if (!config.enabled) { - logInfo('[kuaishou-industry/consume-callback]', '行业电子凭证配置未启用,跳过核销回调') - return { success: true } - } - - try { - const tokenResult = await ensureKuaishouIndustryAccessToken({ - config, - ...(input.sellerId ? { sellerId: input.sellerId } : {}), - }) - config = { - ...config, - ...tokenResult.config, - accessToken: tokenResult.accessToken, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', { - error: message, - }) - return { success: false, error: message } - } - - if (!config.accessToken) { - const message = 'accessToken 未配置,无法发起核销回调' - logWarn('[kuaishou-industry/consume-callback]', message) - return { success: false, error: message } - } - +export async function consumeCallback( + input: ConsumeCallbackInput, +): Promise<{ success: boolean; response?: JsonObject; error?: string }> { const bizParams: JsonObject = { oid: input.oid, etickets: input.etickets.map((e) => { @@ -90,121 +54,63 @@ export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ su if (input.seriallNum) bizParams.seriallNum = input.seriallNum if (input.consumePoiId != null) bizParams.consumePoiId = input.consumePoiId - const paramStr = JSON.stringify(bizParams) - - const signParams: JsonObject = { - method: 'integration.callback.virtual.eticket.consume', - appkey: config.appKey, - access_token: config.accessToken, - version: '1', - timestamp: Date.now(), - signMethod: 'MD5', - param: paramStr, - } - - const sign = signPayload(signParams, config.signSecret) - signParams.sign = sign - - const body = new URLSearchParams() - for (const [key, value] of Object.entries(signParams)) { - body.append(key, String(value)) - } - - const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/consume` - - logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, { - url, - oid: input.oid, - sellerId: input.sellerId || '', - status: input.status, - consumeType: input.consumeType, + const result = await requestKuaishouIndustryOpenApi({ + apiMethod: 'integration.callback.virtual.eticket.consume', + path: '/integration/callback/virtual/eticket/consume', + bizParams, + ...(input.sellerId ? { sellerId: input.sellerId } : {}), + onRequest: (request) => { + logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, { + ...request, + oid: input.oid, + sellerId: input.sellerId || '', + status: input.status, + consumeType: input.consumeType, + }) + }, }) - try { - const startedAt = Date.now() - const res = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: body.toString(), + if (result.skippedReason === 'disabled') { + logInfo('[kuaishou-industry/consume-callback]', '行业电子凭证配置未启用,跳过核销回调') + return { success: true } + } + + if (result.skippedReason === 'token_error') { + const message = result.error || 'accessToken 刷新失败' + logWarn('[kuaishou-industry/consume-callback]', 'accessToken 刷新失败,无法发起核销回调', { + error: message, }) - const text = await res.text() - let json: JsonObject = {} - try { json = JSON.parse(text) } catch { json = { raw: text } } - - const durationMs = Date.now() - startedAt - const ok = res.ok && Number(json.result) === 1 - - if (ok) { - logInfo('[kuaishou-industry/consume-callback]', `核销回调成功 oid=${input.oid}`, { - durationMs, - response: json, - }) - } else { - logWarn('[kuaishou-industry/consume-callback]', `核销回调失败 oid=${input.oid}`, { - durationMs, - status: res.status, - response: json, - }) - } - - return { success: ok, response: json } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logWarn('[kuaishou-industry/consume-callback]', `核销回调异常 oid=${input.oid}`, resolveCallbackErrorDetail(err)) return { success: false, error: message } } -} -function signPayload(params: JsonObject, signSecret: string): string { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign' && key !== 'signSecret') - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifySignValue(value)}`) - .join('&') - - const source = `${queryString}&signSecret=${signSecret}` - - return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase() -} - -function stringifySignValue(value: unknown): string { - if (typeof value === 'number' && Number.isFinite(value)) return String(value) - if (typeof value === 'boolean') return value ? 'true' : 'false' - if (value == null) return '' - if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record).sort()) - return String(value) -} - -function resolveCallbackErrorDetail(error: unknown): JsonObject { - const detail: JsonObject = { - error: error instanceof Error ? error.message : String(error), - } - const cause = error && typeof error === 'object' && 'cause' in error - ? (error as { cause?: unknown }).cause - : null - if (!cause || typeof cause !== 'object') { - return detail + if (result.skippedReason === 'missing_access_token') { + const message = 'accessToken 未配置,无法发起核销回调' + logWarn('[kuaishou-industry/consume-callback]', message) + return { success: false, error: message } } - const current = cause as Record - const fields: Array<[string, unknown]> = [ - ['causeMessage', current.message], - ['causeCode', current.code], - ['causeSyscall', current.syscall], - ['causeHostname', current.hostname], - ] - for (const [key, value] of fields) { - const text = String(value || '').trim() - if (text) { - detail[key] = text - } + if (result.success) { + logInfo('[kuaishou-industry/consume-callback]', `核销回调成功 oid=${input.oid}`, { + durationMs: result.durationMs, + response: result.response || null, + }) + } else if (result.error) { + logWarn( + '[kuaishou-industry/consume-callback]', + `核销回调异常 oid=${input.oid}`, + result.errorDetail || { error: result.error }, + ) + } else { + logWarn('[kuaishou-industry/consume-callback]', `核销回调失败 oid=${input.oid}`, { + durationMs: result.durationMs, + status: result.httpStatus, + response: result.response || null, + }) } - return detail -} - -function resolveKuaishouOpenApiBaseUrl(value: unknown): string { - return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API + return { + success: result.success, + ...(result.response ? { response: result.response } : {}), + ...(result.error ? { error: result.error } : {}), + } } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts b/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts index 7f7dd3d9..0b23ee10 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/crypto.ts @@ -6,7 +6,7 @@ import { assertKuaishouIndustryConfig } from './config.js' type JsonObject = Record -type SignMethod = 'MD5' | 'HMAC_SHA256' +export type SignMethod = 'MD5' | 'HMAC_SHA256' export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signSecret } = assertKuaishouIndustryConfig()) { const entries = Object.entries(params) diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts index e67690d6..c234b657 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts @@ -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 - -const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' +import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js' type DestroyCallbackInput = { oid: string @@ -23,38 +16,9 @@ type DestroyCallbackInput = { token?: string } -export async function destroyCallback(input: DestroyCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { - let config = getKuaishouIndustryConfig() - - if (!config.enabled) { - logInfo('[kuaishou-industry/destroy-callback]', '行业电子凭证配置未启用,跳过销毁回调') - return { success: true } - } - - try { - const tokenResult = await ensureKuaishouIndustryAccessToken({ - config, - ...(input.sellerId ? { sellerId: input.sellerId } : {}), - }) - config = { - ...config, - ...tokenResult.config, - accessToken: tokenResult.accessToken, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', { - error: message, - }) - return { success: false, error: message } - } - - if (!config.accessToken) { - const message = 'accessToken 未配置,无法发起销毁回调' - logWarn('[kuaishou-industry/destroy-callback]', message) - return { success: false, error: message } - } - +export async function destroyCallback( + input: DestroyCallbackInput, +): Promise<{ success: boolean; response?: JsonObject; error?: string }> { const bizParams: JsonObject = { oid: input.oid, reason: input.reason, @@ -73,121 +37,63 @@ export async function destroyCallback(input: DestroyCallbackInput): Promise<{ su if (input.ext) bizParams.ext = input.ext if (input.token) bizParams.token = input.token - const paramStr = JSON.stringify(bizParams) - - const signParams: JsonObject = { - method: 'integration.callback.virtual.eticket.destroy', - appkey: config.appKey, - access_token: config.accessToken, - version: '1', - timestamp: Date.now(), - signMethod: 'MD5', - param: paramStr, - } - - const sign = signPayload(signParams, config.signSecret) - signParams.sign = sign - - const body = new URLSearchParams() - for (const [key, value] of Object.entries(signParams)) { - body.append(key, String(value)) - } - - const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/destroy` - - logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, { - url, - oid: input.oid, - sellerId: input.sellerId || '', - reason: input.reason, - hasToken: Boolean(input.token), + const result = await requestKuaishouIndustryOpenApi({ + apiMethod: 'integration.callback.virtual.eticket.destroy', + path: '/integration/callback/virtual/eticket/destroy', + bizParams, + ...(input.sellerId ? { sellerId: input.sellerId } : {}), + onRequest: (request) => { + logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, { + ...request, + oid: input.oid, + sellerId: input.sellerId || '', + reason: input.reason, + hasToken: Boolean(input.token), + }) + }, }) - try { - const startedAt = Date.now() - const res = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: body.toString(), + if (result.skippedReason === 'disabled') { + logInfo('[kuaishou-industry/destroy-callback]', '行业电子凭证配置未启用,跳过销毁回调') + return { success: true } + } + + if (result.skippedReason === 'token_error') { + const message = result.error || 'accessToken 刷新失败' + logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 刷新失败,无法发起销毁回调', { + error: message, }) - const text = await res.text() - let json: JsonObject = {} - try { json = JSON.parse(text) } catch { json = { raw: text } } - - const durationMs = Date.now() - startedAt - const ok = res.ok && Number(json.result) === 1 - - if (ok) { - logInfo('[kuaishou-industry/destroy-callback]', `销毁回调成功 oid=${input.oid}`, { - durationMs, - response: json, - }) - } else { - logWarn('[kuaishou-industry/destroy-callback]', `销毁回调失败 oid=${input.oid}`, { - durationMs, - status: res.status, - response: json, - }) - } - - return { success: ok, response: json } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logWarn('[kuaishou-industry/destroy-callback]', `销毁回调异常 oid=${input.oid}`, resolveCallbackErrorDetail(err)) return { success: false, error: message } } -} -function signPayload(params: JsonObject, signSecret: string): string { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign' && key !== 'signSecret') - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifySignValue(value)}`) - .join('&') - - const source = `${queryString}&signSecret=${signSecret}` - - return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase() -} - -function stringifySignValue(value: unknown): string { - if (typeof value === 'number' && Number.isFinite(value)) return String(value) - if (typeof value === 'boolean') return value ? 'true' : 'false' - if (value == null) return '' - if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record).sort()) - return String(value) -} - -function resolveCallbackErrorDetail(error: unknown): JsonObject { - const detail: JsonObject = { - error: error instanceof Error ? error.message : String(error), - } - const cause = error && typeof error === 'object' && 'cause' in error - ? (error as { cause?: unknown }).cause - : null - if (!cause || typeof cause !== 'object') { - return detail + if (result.skippedReason === 'missing_access_token') { + const message = 'accessToken 未配置,无法发起销毁回调' + logWarn('[kuaishou-industry/destroy-callback]', message) + return { success: false, error: message } } - const current = cause as Record - const fields: Array<[string, unknown]> = [ - ['causeMessage', current.message], - ['causeCode', current.code], - ['causeSyscall', current.syscall], - ['causeHostname', current.hostname], - ] - for (const [key, value] of fields) { - const text = String(value || '').trim() - if (text) { - detail[key] = text - } + if (result.success) { + logInfo('[kuaishou-industry/destroy-callback]', `销毁回调成功 oid=${input.oid}`, { + durationMs: result.durationMs, + response: result.response || null, + }) + } else if (result.error) { + logWarn( + '[kuaishou-industry/destroy-callback]', + `销毁回调异常 oid=${input.oid}`, + result.errorDetail || { error: result.error }, + ) + } else { + logWarn('[kuaishou-industry/destroy-callback]', `销毁回调失败 oid=${input.oid}`, { + durationMs: result.durationMs, + status: result.httpStatus, + response: result.response || null, + }) } - return detail -} - -function resolveKuaishouOpenApiBaseUrl(value: unknown): string { - return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API + return { + success: result.success, + ...(result.response ? { response: result.response } : {}), + ...(result.error ? { error: result.error } : {}), + } } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/openapi-client.ts b/apps/backend/src/services/platforms/kuaishou-industry/openapi-client.ts new file mode 100644 index 00000000..792224c9 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/openapi-client.ts @@ -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 + +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 { + 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 + 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}` +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/openapi-values.ts b/apps/backend/src/services/platforms/kuaishou-industry/openapi-values.ts new file mode 100644 index 00000000..15b25a29 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/openapi-values.ts @@ -0,0 +1,53 @@ +export type JsonObject = Record + +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) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/refund-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/refund-service.ts new file mode 100644 index 00000000..2c9fd6ad --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/refund-service.ts @@ -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() } : {}), + }) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/reverse-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/reverse-callback-service.ts new file mode 100644 index 00000000..2b2dcd74 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/reverse-callback-service.ts @@ -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 +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts index c84cb9c9..b7ca24ad 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts @@ -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 - -const DEFAULT_KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' +import { requestKuaishouIndustryOpenApi, type JsonObject } from './openapi-client.js' type SendCallbackInput = { oid: string @@ -29,37 +22,6 @@ type SendCallbackInput = { } export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> { - let config = getKuaishouIndustryConfig() - - if (!config.enabled) { - logIntegration('[kuaishou-industry/send-callback]', '行业电子凭证配置未启用,跳过发货回调') - return { success: true } - } - - try { - const tokenResult = await ensureKuaishouIndustryAccessToken({ - config, - ...(input.sellerId ? { sellerId: input.sellerId } : {}), - }) - config = { - ...config, - ...tokenResult.config, - accessToken: tokenResult.accessToken, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logIntegration('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发货回调', { - error: message, - }, { level: 'warn' }) - return { success: false, error: message } - } - - if (!config.accessToken) { - const message = 'accessToken 未配置,无法发起发货回调' - logIntegration('[kuaishou-industry/send-callback]', message, undefined, { level: 'warn' }) - return { success: false, error: message } - } - const bizParams: JsonObject = { oid: input.oid, sendType: input.sendType, @@ -83,153 +45,70 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success: if (input.expressCode) bizParams.expressCode = input.expressCode if (input.expressNo) bizParams.expressNo = input.expressNo - const paramStr = JSON.stringify(bizParams) + const result = await requestKuaishouIndustryOpenApi({ + apiMethod: 'integration.callback.virtual.eticket.send', + path: '/integration/callback/virtual/eticket/send', + bizParams, + ...(input.sellerId ? { sellerId: input.sellerId } : {}), + onRequest: (request) => { + const requestLog = { + sellerId: input.sellerId || '', + ...request, + } + logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog) + }, + }) - const signParams: JsonObject = { - method: 'integration.callback.virtual.eticket.send', - appkey: config.appKey, - access_token: config.accessToken, - version: '1', - timestamp: Date.now(), - signMethod: 'MD5', - param: paramStr, + if (result.skippedReason === 'disabled') { + logIntegration('[kuaishou-industry/send-callback]', '行业电子凭证配置未启用,跳过发货回调') + return { success: true } } - const sign = signPayload(signParams, config.signSecret) - signParams.sign = sign - - const body = new URLSearchParams() - for (const [key, value] of Object.entries(signParams)) { - body.append(key, String(value)) - } - - const url = `${resolveKuaishouOpenApiBaseUrl(config.baseUrl)}/integration/callback/virtual/eticket/send` - const requestLog = { - sellerId: input.sellerId || '', - ...buildSendCallbackRequestLog({ - url, - signParams, - bizParams, - }), - } - - logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog) - - try { - const startedAt = Date.now() - const res = await fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }) - const text = await res.text() - let json: JsonObject = {} - try { json = JSON.parse(text) } catch { json = { raw: text } } - - const durationMs = Date.now() - startedAt - const ok = res.ok && Number(json.result) === 1 - - if (ok) { - logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调成功 oid=${input.oid}`, { - durationMs, - status: res.status, - request: requestLog, - response: json, - }) - } else { - logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调失败 oid=${input.oid}`, { - durationMs, - status: res.status, - request: requestLog, - response: json, - }, { level: 'warn' }) - } - - return { success: ok, response: json } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调异常 oid=${input.oid}`, { - ...resolveCallbackErrorDetail(err), - request: requestLog, + if (result.skippedReason === 'token_error') { + const message = result.error || 'accessToken 刷新失败' + logIntegration('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发货回调', { + error: message, }, { level: 'warn' }) return { success: false, error: message } } -} -function buildSendCallbackRequestLog({ - url, - signParams, - bizParams, -}: { - url: string - signParams: JsonObject - bizParams: JsonObject -}): JsonObject { + if (result.skippedReason === 'missing_access_token') { + const message = 'accessToken 未配置,无法发起发货回调' + logIntegration('[kuaishou-industry/send-callback]', message, undefined, { level: 'warn' }) + return { success: false, error: message } + } + + const requestLog = result.request + ? { + sellerId: input.sellerId || '', + ...result.request, + } + : undefined + + if (result.success) { + logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调成功 oid=${input.oid}`, { + durationMs: result.durationMs, + status: result.httpStatus, + request: requestLog, + response: result.response || null, + }) + } else if (result.error) { + logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调异常 oid=${input.oid}`, { + ...(result.errorDetail || { error: result.error }), + request: requestLog, + }, { level: 'warn' }) + } else { + logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调失败 oid=${input.oid}`, { + durationMs: result.durationMs, + status: result.httpStatus, + request: requestLog, + response: result.response || null, + }, { level: 'warn' }) + } + return { - url, - method: 'POST', - contentType: 'application/x-www-form-urlencoded', - apiMethod: signParams.method, - appkey: signParams.appkey, - access_token: signParams.access_token, - version: signParams.version, - timestamp: signParams.timestamp, - signMethod: signParams.signMethod, - sign: signParams.sign, - param: bizParams, + success: result.success, + ...(result.response ? { response: result.response } : {}), + ...(result.error ? { error: result.error } : {}), } } - -function signPayload(params: JsonObject, signSecret: string): string { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign' && key !== 'signSecret') - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifySignValue(value)}`) - .join('&') - - const source = `${queryString}&signSecret=${signSecret}` - - return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase() -} - -function stringifySignValue(value: unknown): string { - if (typeof value === 'number' && Number.isFinite(value)) return String(value) - if (typeof value === 'boolean') return value ? 'true' : 'false' - if (value == null) return '' - if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as Record).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 - 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 -} diff --git a/apps/backend/src/types/admin/route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts index f8652df3..461c0ff9 100644 --- a/apps/backend/src/types/admin/route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -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 diff --git a/apps/backend/src/types/admin/write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts index eee530f9..bdffcf9a 100644 --- a/apps/backend/src/types/admin/write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -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 +} + +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 + 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 +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index adc06cbe..88594d70 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> }> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 35539cc8..fc8a8a74 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -6,6 +6,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, OrderedListOutlined, + SafetyCertificateOutlined, SettingOutlined, ShopOutlined, TeamOutlined, @@ -44,6 +45,7 @@ export default function AdminLayout() { { key: '/admin/dashboard', icon: , label: '概览' }, { key: '/admin/orders', icon: , label: '订单' }, { key: '/admin/tasks', icon: , label: '任务' }, + { key: '/admin/kuaishou-industry', icon: , label: '电子凭证' }, { key: '/admin/cloudtentacles-records', icon: , 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 } diff --git a/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx new file mode 100644 index 00000000..65302d0d --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminKuaishouIndustryPage.tsx @@ -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([]) + const [voucherTotal, setVoucherTotal] = useState(0) + const [voucherFilters, setVoucherFilters] = useState({ + oid: '', + voucherCode: '', + taskId: '', + sellerId: '', + status: '', + page: 1, + pageSize: 50, + }) + const [toolForm, setToolForm] = useState({ + eticketType: DEFAULT_ETICKET_TYPE, + consumeType: 'delivery', + }) + const [refundDateRange, setRefundDateRange] = useState(() => [ + dayjs().subtract(1, 'day'), + dayjs(), + ]) + const [refundListForm, setRefundListForm] = useState({ + sellerId: '', + orderId: '', + type: '8', + pageSize: '50', + currentPage: '1', + pcursor: '', + status: '10', + negotiateStatus: '', + }) + const [approveForm, setApproveForm] = useState({ + refundId: '', + refundAmount: '', + status: '', + negotiateStatus: '', + refundHandingWay: '10', + }) + const [disagreeForm, setDisagreeForm] = useState({ + refundId: '', + sellerDisagreeReason: '100', + sellerDisagreeDesc: '', + status: '10', + negotiateStatus: '1', + }) + const [lastResultTitle, setLastResultTitle] = useState('接口结果') + const [lastResult, setLastResult] = useState(null) + const [refundRows, setRefundRows] = useState>>([]) + + const columns = useMemo>( + () => [ + { + title: '券码', + minWidth: 220, + render: (_, row) => ( +
+ + {row.voucherCode} + + 序号:{row.unitIndex} +
+ ), + }, + { + title: '订单 / 任务', + minWidth: 220, + render: (_, row) => ( +
+ {row.oid || '-'} + + 任务:{row.taskId || '-'} / 订单:{row.orderId || '-'} + +
+ ), + }, + { + title: '状态', + width: 150, + render: (_, row) => ( +
+ {getVoucherStatusLabel(row.status)} + + 发码:{row.sendCallbackStatus || '-'} + +
+ ), + }, + { + title: '卖家', + minWidth: 160, + render: (_, row) => ( +
+ {row.sellerId || '-'} + Token:{row.tokenMasked || '-'} +
+ ), + }, + { + title: '核销', + minWidth: 180, + render: (_, row) => ( +
+ {row.consumeSerialNum || '-'} + {formatAdminDateTime(row.consumedAt)} +
+ ), + }, + { + title: '更新时间', + width: 170, + render: (_, row) => formatAdminDateTime(row.updatedAt), + }, + { + title: '操作', + fixed: 'right', + width: 120, + render: (_, row) => ( + + ), + }, + ], + [], + ) + + const refundColumns = useMemo>>( + () => [ + { 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) => ( + + ), + }, + ], + [], + ) + + useEffect(() => { + void loadVouchers() + }, []) + + async function loadVouchers(nextFilters: Partial = {}) { + 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) { + 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 ( +
+ + + +
+ ) + + function renderVoucherTab() { + return ( +
+ + + setVoucherFilters({ ...voucherFilters, oid: event.target.value })} + /> + + setVoucherFilters({ ...voucherFilters, voucherCode: event.target.value }) + } + /> + + setVoucherFilters({ ...voucherFilters, taskId: event.target.value }) + } + /> + + setVoucherFilters({ ...voucherFilters, sellerId: event.target.value }) + } + /> + updateToolForm({ sellerId: event.target.value })} + /> + + updateToolForm({ oid: event.target.value, orderId: event.target.value }) + } + /> + updateToolForm({ taskId: event.target.value })} + /> + updateToolForm({ voucherCode: event.target.value })} + /> + updateToolForm({ eticketType: event.target.value })} + /> + updateToolForm({ serialNum: event.target.value })} + /> + updateToolForm({ consumeType: event.target.value })} + /> + updateToolForm({ reason: event.target.value })} + /> + updateToolForm({ storeName: event.target.value })} + /> + updateToolForm({ storeAddress: event.target.value })} + /> +
+ + + + + + + + + + ) + } + + function renderRefundTab() { + return ( +
+ + + updateRefundListForm({ sellerId: event.target.value })} + /> + updateRefundListForm({ orderId: event.target.value })} + /> + setRefundDateRange(value)} + /> + updateRefundListForm({ status: event.target.value })} /> + updateRefundListForm({ pcursor: event.target.value })} /> + updateRefundListForm({ pageSize: String(value || 50) })} + /> + + + String(row.refundId || row.oid || JSON.stringify(row))} + columns={refundColumns} + dataSource={refundRows} + scroll={{ x: 980 }} + pagination={{ pageSize: 10 }} + style={{ marginTop: 16 }} + /> + + +
+ +
+ setApproveForm({ ...approveForm, sellerId: event.target.value })} + /> + setApproveForm({ ...approveForm, refundId: event.target.value })} + /> + + setApproveForm({ ...approveForm, refundAmount: event.target.value }) + } + /> + setApproveForm({ ...approveForm, status: event.target.value })} + /> + + setApproveForm({ ...approveForm, negotiateStatus: event.target.value }) + } + /> + + setApproveForm({ ...approveForm, refundHandingWay: event.target.value }) + } + /> + setApproveForm({ ...approveForm, desc: event.target.value })} + /> +
+ +
+ + +
+ + setDisagreeForm({ ...disagreeForm, sellerId: event.target.value }) + } + /> + + setDisagreeForm({ ...disagreeForm, refundId: event.target.value }) + } + /> + + setDisagreeForm({ ...disagreeForm, sellerDisagreeReason: event.target.value }) + } + /> + + setDisagreeForm({ ...disagreeForm, sellerDisagreeDesc: event.target.value }) + } + /> + + setDisagreeForm({ ...disagreeForm, status: event.target.value }) + } + /> + + setDisagreeForm({ ...disagreeForm, negotiateStatus: event.target.value }) + } + /> +
+ +
+
+ + ) + } + + function renderResultTab() { + return ( + } onClick={() => setLastResult(null)}> + 清空 + + } + > + {lastResult ? ( +
{stringifyDisplayJson(lastResult)}
+ ) : ( + + )} +
+ ) + } + + function updateToolForm(patch: Partial) { + setToolForm((current) => ({ ...current, ...patch })) + } + + function updateRefundListForm(patch: Partial) { + setRefundListForm((current) => ({ ...current, ...patch })) + } +} + +function extractRefundRows(response: Record | null): Array> { + 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 => + 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()) +} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 99c97f94..d79ed4fc 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -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' diff --git a/apps/frontend/src/services/admin/kuaishou-industry.ts b/apps/frontend/src/services/admin/kuaishou-industry.ts new file mode 100644 index 00000000..ab45c787 --- /dev/null +++ b/apps/frontend/src/services/admin/kuaishou-industry.ts @@ -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) { + return apiGet( + '/api/v1/admin/kuaishou-industry/vouchers', + params, + ) +} + +export function listAdminKuaishouIndustryRefunds( + payload: AdminKuaishouIndustryRefundListPayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/refunds/list', + payload, + ) +} + +export function approveAdminKuaishouIndustryRefund( + payload: AdminKuaishouIndustryRefundApprovePayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/refunds/approve', + payload, + ) +} + +export function disagreeAdminKuaishouIndustryRefund( + payload: AdminKuaishouIndustryRefundDisagreePayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/refunds/disagree', + payload, + ) +} + +export function checkAdminKuaishouIndustryVoucherAvailable( + payload: AdminKuaishouIndustryVoucherToolPayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/vouchers/check-available', + payload, + ) +} + +export function reverseAdminKuaishouIndustryVoucher( + payload: AdminKuaishouIndustryVoucherToolPayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/vouchers/reverse', + payload, + ) +} + +export function consumeAdminKuaishouIndustryVoucher( + payload: AdminKuaishouIndustryVoucherToolPayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/vouchers/consume', + payload, + ) +} + +export function resendAdminKuaishouIndustryVoucherCode( + payload: AdminKuaishouIndustryVoucherToolPayload, +) { + return apiPost( + '/api/v1/admin/kuaishou-industry/vouchers/resend-code', + payload, + ) +} diff --git a/apps/frontend/src/styles/main.css b/apps/frontend/src/styles/main.css index 7141d3d5..92bb460c 100644 --- a/apps/frontend/src/styles/main.css +++ b/apps/frontend/src/styles/main.css @@ -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 { diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index da7be670..6a82627a 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -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, diff --git a/apps/frontend/src/types/admin/kuaishou-industry.ts b/apps/frontend/src/types/admin/kuaishou-industry.ts new file mode 100644 index 00000000..df818755 --- /dev/null +++ b/apps/frontend/src/types/admin/kuaishou-industry.ts @@ -0,0 +1,114 @@ +import type { AdminPagination } from './common' + +export interface AdminKuaishouIndustryOpenApiResult { + success: boolean + response: Record | null + error: string + request: Record | 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> + consumedAt: string | null + destroyedAt: string | null + sendCallbackStatus: string + sendCallbackAttemptCount: number + sendCallbackLastError: string + sendCallbackSentAt: string | null + rawPayload: Record + 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 | 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 }> +} diff --git a/docs/行业电子凭证/解决方案/相关文档/1.1.新增退款单消息.md b/docs/行业电子凭证/解决方案/相关文档/1.1.新增退款单消息.md new file mode 100644 index 00000000..7d4239a9 --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/1.1.新增退款单消息.md @@ -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 +} \ No newline at end of file diff --git a/docs/行业电子凭证/解决方案/相关文档/1.2.售后单列表(游标方式).md b/docs/行业电子凭证/解决方案/相关文档/1.2.售后单列表(游标方式).md new file mode 100644 index 00000000..4ba7bdba --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/1.2.售后单列表(游标方式).md @@ -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" +} diff --git a/docs/行业电子凭证/解决方案/相关文档/2.1.商家同意退款接口.md b/docs/行业电子凭证/解决方案/相关文档/2.1.商家同意退款接口.md new file mode 100644 index 00000000..118dfd35 --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/2.1.商家同意退款接口.md @@ -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" +} \ No newline at end of file diff --git a/docs/行业电子凭证/解决方案/相关文档/2.2.商家不同意退款接口.md b/docs/行业电子凭证/解决方案/相关文档/2.2.商家不同意退款接口.md new file mode 100644 index 00000000..1958ca60 --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/2.2.商家不同意退款接口.md @@ -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 否 [] 拒绝图片,最多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 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" +} \ No newline at end of file diff --git a/docs/行业电子凭证/解决方案/相关文档/3.电子凭证冲正回调接口 copy.md b/docs/行业电子凭证/解决方案/相关文档/3.电子凭证冲正回调接口 copy.md new file mode 100644 index 00000000..a196230e --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/3.电子凭证冲正回调接口 copy.md @@ -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 是 [] 冲正的卡券列表 +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 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\"" +} \ No newline at end of file diff --git a/docs/行业电子凭证/解决方案/相关文档/4.电子凭证检查电子凭证是否有效.md b/docs/行业电子凭证/解决方案/相关文档/4.电子凭证检查电子凭证是否有效.md new file mode 100644 index 00000000..e06173bc --- /dev/null +++ b/docs/行业电子凭证/解决方案/相关文档/4.电子凭证检查电子凭证是否有效.md @@ -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 是 [] 电子凭证列表 +orderId Long 否 1 订单编号 +sellerId Long 是 211 卖家编号 +返回参数 +参数名 类型 示例值 详情描述 +code String 1 主返回码 +msg String success 主返回信息 +sub_code String 1 子返回码 +sub_msg String 1 子返回信息 +etickets List [] 电子凭证校验结果 +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 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" + } +}