Files
order_site/apps/backend/src/services/admin/kuaishou-industry-admin-service.ts
T

648 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { asJsonObject, type JsonObject } from '../../types/json.js'
import {
findKuaishouIndustryVoucherByCode,
listKuaishouIndustryVouchersByOid,
listKuaishouIndustryVouchersByTaskId,
listKuaishouIndustryVouchersForAdmin,
updateKuaishouIndustryVoucherByCode,
type KuaishouIndustryVoucherAdminListQuery,
type KuaishouIndustryVoucherAdminListRow,
} 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,
isKuaishouIndustryEticketDestroyableStatus,
KUAISHOU_INDUSTRY_DESTROYABLE_DISPLAY_STATUS,
resolveKuaishouIndustryEticketDisplayStatus,
} from '../platforms/kuaishou-industry/check-available-service.js'
import {
getKuaishouIndustrySourceConfig,
listKuaishouIndustryShopConfigs,
type KuaishouIndustrySourceConfig,
} from '../platforms/kuaishou-industry/source-config-service.js'
import { resendKuaishouIndustryVoucherSendCallback } from '../platforms/kuaishou-industry/send-code-service.js'
import {
consumeKuaishouIndustryVoucher,
destroyKuaishouIndustryVoucher,
resolveKuaishouIndustryEticketType,
} 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 { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
import type { KuaishouIndustryOpenApiCallResult } from '../platforms/kuaishou-industry/openapi-client.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
export async function listAdminKuaishouIndustryVouchers(
input: KuaishouIndustryVoucherAdminListQuery = {},
) {
const result = await listKuaishouIndustryVouchersForAdmin(input)
const shopNameBySellerId = buildKuaishouIndustryShopNameMap()
return {
items: result.items.map((voucher) =>
mapAdminKuaishouIndustryVoucher(voucher, shopNameBySellerId),
),
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
},
}
}
/** 轻量店铺选项:仅展示名与 ID,供筛选/下拉使用(不含 token 等敏感字段) */
export function listAdminKuaishouIndustryShops() {
const shops = listKuaishouIndustryShopConfigs(getKuaishouIndustrySourceConfig())
return {
shops: shops
.map((shop) => {
const sellerId = String(shop.sellerId || '').trim()
const shopId = String(shop.shopId || '').trim()
const shopName = String(shop.customShopName || shop.shopName || '').trim()
return {
sellerId,
shopId,
shopName,
enabled: shop.enabled !== false,
}
})
.filter((shop) => shop.sellerId || shop.shopId),
}
}
export async function listAdminKuaishouIndustryRefunds(input: JsonObject = {}) {
assertRequired(input.beginTime, '开始时间未填写')
assertRequired(input.endTime, '结束时间未填写')
const payload = await buildRefundOpenApiPayload(input)
return mapOpenApiResult(await listKuaishouIndustryRefunds(payload))
}
export async function approveAdminKuaishouIndustryRefund(input: JsonObject = {}) {
assertRequired(input.refundId, '退款单编号未填写')
assertRequired(input.refundAmount, '退款金额未填写')
const payload = await buildRefundOpenApiPayload(input)
return mapOpenApiResult(await approveKuaishouIndustryRefund(payload))
}
export async function disagreeAdminKuaishouIndustryRefund(input: JsonObject = {}) {
assertRequired(input.refundId, '退款单编号未填写')
assertRequired(input.sellerDisagreeReason, '拒绝原因未填写')
assertRequired(input.sellerDisagreeDesc, '拒绝说明未填写')
assertRequired(input.status, '退款单当前状态未填写')
assertRequired(input.negotiateStatus, '协商状态未填写')
const payload = await buildRefundOpenApiPayload(input)
return mapOpenApiResult(await disagreeKuaishouIndustryRefund(payload))
}
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, '电子凭证列表未填写')
const result = mapOpenApiResult(await checkKuaishouIndustryEticketAvailable(payload))
return {
...result,
eticketType: String(payload.eticketType || payload.bizTypeCode || ''),
voucher: voucher ? mapAdminKuaishouIndustryVoucher(voucher) : null,
}
}
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 consumeType = String(input.consumeType || '').trim()
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
source: 'admin_tool_manual_consume',
token: String(input.token || voucher.token || '').trim(),
consumeTime: Date.now(),
...(consumeType ? { consumeType } : {}),
...(input.serialNum ? { serialNum: String(input.serialNum).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,
}
}
export async function destroyAdminKuaishouIndustryVoucherByCode(input: JsonObject = {}) {
const voucher = await resolveRequiredVoucher(input)
const checkPayload = buildVoucherOpenApiPayload(input, voucher)
assertRequired(checkPayload.sellerId, '卖家编号未填写,无法在销毁前查询卡券状态')
assertRequired(
checkPayload.eticketType || checkPayload.bizTypeCode,
'电子凭证类型未填写,无法在销毁前查询卡券状态',
)
assertEtickets(checkPayload.etickets, '电子凭证列表未填写,无法在销毁前查询卡券状态')
const checkResult = await checkKuaishouIndustryEticketAvailable(checkPayload)
if (!checkResult.success || checkResult.skippedReason) {
const platformMessage = String(
checkResult.error || checkResult.response?.error_msg || checkResult.response?.msg || '',
).trim()
throw createHttpError(
`销毁前查询卡券状态失败,已取消销毁${platformMessage ? `:${platformMessage}` : ''}`,
{
statusCode: 409,
errorCode: 'admin_kuaishou_industry_destroy_status_check_failed',
},
)
}
const displayStatus = resolveKuaishouIndustryEticketDisplayStatus(
checkResult.response,
voucher.voucher_code,
)
if (!isKuaishouIndustryEticketDestroyableStatus(displayStatus)) {
throw createHttpError(
displayStatus
? `卡券当前展示状态为 ${displayStatus},仅 ${KUAISHOU_INDUSTRY_DESTROYABLE_DISPLAY_STATUS}(冻结)状态可以销毁`
: `未获取到卡券展示状态,仅 ${KUAISHOU_INDUSTRY_DESTROYABLE_DISPLAY_STATUS}(冻结)状态可以销毁`,
{
statusCode: 409,
errorCode: 'admin_kuaishou_industry_voucher_not_frozen',
},
)
}
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
const destroyInput: Parameters<typeof destroyKuaishouIndustryVoucher>[1] = {
source: 'admin_tool_manual_destroy',
reason: String(input.reason || '').trim(),
token: String(input.token || voucher.token || '').trim(),
eticketType: resolveKuaishouIndustryEticketType(voucher, input.eticketType),
}
if (task) {
destroyInput.task = task
}
const result = await destroyKuaishouIndustryVoucher(voucher, destroyInput)
if (!result.ok || !result.voucher) {
throw createHttpError(result.errorMessage || '电子凭证销毁失败', {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_destroy_failed',
})
}
const nextTask = result.task || task
if (nextTask && result.voucher && !result.alreadyDestroyed) {
await attachKuaishouIndustryVoucherToTask(nextTask, result.voucher, {
source: 'admin_tool_manual_destroy',
now: new Date().toISOString(),
})
}
return {
success: true,
alreadyDestroyed: Boolean(result.alreadyDestroyed),
reason: result.reason || '',
voucher: mapAdminKuaishouIndustryVoucher(result.voucher),
task: nextTask ? mapTaskReference(nextTask) : null,
}
}
function buildVoucherOpenApiPayload(
input: JsonObject,
voucher: KuaishouIndustryVoucherRow | null,
): JsonObject {
const etickets = normalizeAdminEtickets(input.etickets)
const voucherCode = String(voucher?.voucher_code || input.voucherCode || '').trim()
const eticketType = resolveKuaishouIndustryEticketType(
voucher,
input.eticketType || input.bizTypeCode,
)
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(),
eticketType,
...(eticketType ? { bizTypeCode: eticketType } : {}),
etickets:
etickets.length > 0
? etickets
: voucherCode
? [{ id: voucherCode, code: voucherCode, num: 1 }]
: [],
}
}
async function buildRefundOpenApiPayload(input: JsonObject): Promise<JsonObject> {
const source = getKuaishouIndustrySourceConfig()
const sellerId =
String(input.sellerId || '').trim() ||
(await resolveRefundSellerIdFromLocalVoucher(input)) ||
resolveSingleEnabledKuaishouIndustrySellerId(source)
if (!sellerId && hasMultipleEnabledKuaishouIndustryShops(source)) {
throw createHttpError('存在多个快手售后退款店铺授权,请先选择 sellerId', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_refund_seller_id_required',
})
}
return {
...input,
...(sellerId ? { sellerId } : {}),
}
}
async function resolveRefundSellerIdFromLocalVoucher(input: JsonObject): Promise<string> {
const voucherCode = String(input.voucherCode || '').trim()
const oid = String(input.oid || input.orderId || '').trim()
if (voucherCode) {
const voucher = await findKuaishouIndustryVoucherByCode(voucherCode, oid)
return String(voucher?.seller_id || '').trim()
}
const taskId = Number(input.taskId || 0)
if (Number.isFinite(taskId) && taskId > 0) {
return resolveSingleSellerIdFromVouchers(
await listKuaishouIndustryVouchersByTaskId(Math.trunc(taskId)),
'当前任务关联多个卖家,请指定 sellerId',
)
}
if (oid) {
return resolveSingleSellerIdFromVouchers(
await listKuaishouIndustryVouchersByOid(oid),
'当前订单关联多个卖家,请指定 sellerId',
)
}
return ''
}
function resolveSingleSellerIdFromVouchers(
vouchers: KuaishouIndustryVoucherRow[],
multipleMessage: string,
): string {
const sellerIds = Array.from(
new Set(vouchers.map((voucher) => String(voucher.seller_id || '').trim()).filter(Boolean)),
)
if (sellerIds.length > 1) {
throw createHttpError(multipleMessage, {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_refund_seller_id_ambiguous',
})
}
return sellerIds[0] || ''
}
function resolveSingleEnabledKuaishouIndustrySellerId(
source: KuaishouIndustrySourceConfig,
): string {
const enabledShops = listEnabledKuaishouIndustryShops(source)
return enabledShops.length === 1
? String(enabledShops[0]?.sellerId || enabledShops[0]?.shopId || '').trim()
: ''
}
function hasMultipleEnabledKuaishouIndustryShops(source: KuaishouIndustrySourceConfig): boolean {
return listEnabledKuaishouIndustryShops(source).length > 1
}
function listEnabledKuaishouIndustryShops(source: KuaishouIndustrySourceConfig) {
return listKuaishouIndustryShopConfigs(source).filter(
(shop) => shop.enabled !== false && String(shop.sellerId || shop.shopId || '').trim(),
)
}
async function resolveOptionalVoucher(
input: JsonObject,
): Promise<KuaishouIndustryVoucherRow | null> {
const voucherCode = String(input.voucherCode || '').trim()
if (!voucherCode) {
return null
}
const voucher = await findKuaishouIndustryVoucherByCode(
voucherCode,
String(input.oid || input.orderId || '').trim(),
)
if (!voucher) {
throw createHttpError('电子凭证不存在', {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_voucher_not_found',
})
}
return voucher
}
async function resolveRequiredVoucher(input: JsonObject): Promise<KuaishouIndustryVoucherRow> {
const voucher = await resolveOptionalVoucher(input)
if (voucher) {
return voucher
}
const taskId = Number(input.taskId || 0)
if (Number.isFinite(taskId) && taskId > 0) {
const vouchers = await listKuaishouIndustryVouchersByTaskId(Math.trunc(taskId))
return resolveSingleVoucher(
vouchers,
'当前任务没有关联电子凭证',
'当前任务关联多个电子凭证,请指定券码',
)
}
const oid = String(input.oid || input.orderId || '').trim()
if (oid) {
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
return resolveSingleVoucher(
vouchers,
'当前订单没有关联电子凭证',
'当前订单关联多个电子凭证,请指定券码',
)
}
throw createHttpError('请填写券码、任务 ID 或订单号', {
statusCode: 400,
errorCode: 'admin_kuaishou_industry_voucher_selector_missing',
})
}
function resolveSingleVoucher(
vouchers: KuaishouIndustryVoucherRow[],
missingMessage: string,
multipleMessage: string,
): KuaishouIndustryVoucherRow {
if (vouchers.length === 0) {
throw createHttpError(missingMessage, {
statusCode: 404,
errorCode: 'admin_kuaishou_industry_voucher_not_found',
})
}
if (vouchers.length > 1) {
throw createHttpError(multipleMessage, {
statusCode: 409,
errorCode: 'admin_kuaishou_industry_voucher_ambiguous',
})
}
return vouchers[0] as KuaishouIndustryVoucherRow
}
function mapOpenApiResult(result: KuaishouIndustryOpenApiCallResult) {
return {
success: result.success,
response: result.response || null,
error: result.error || '',
request: sanitizeOpenApiRequest(result.request),
durationMs: result.durationMs || 0,
httpStatus: result.httpStatus || 0,
skippedReason: result.skippedReason || '',
}
}
function sanitizeOpenApiRequest(request: JsonObject | undefined): JsonObject | null {
if (!request) {
return null
}
return {
...request,
access_token: maskSecret(request.access_token),
sign: maskSecret(request.sign),
}
}
function mapAdminKuaishouIndustryVoucher(
voucher: KuaishouIndustryVoucherRow | KuaishouIndustryVoucherAdminListRow,
shopNameBySellerId: Map<string, string> = buildKuaishouIndustryShopNameMap(),
) {
const sellerId = String(voucher.seller_id || '').trim()
const listRow = voucher as KuaishouIndustryVoucherAdminListRow
return {
id: voucher.id,
voucherCode: voucher.voucher_code,
oid: voucher.oid,
orderId: voucher.order_id,
taskId: voucher.task_id,
taskNo: String(listRow.task_no || '').trim(),
unitIndex: voucher.unit_index,
sellerId,
shopName: shopNameBySellerId.get(sellerId) || '',
skuCode: String(listRow.sku_code || '').trim(),
skuName: String(listRow.sku_name || '').trim(),
tokenMasked: maskSecret(voucher.token),
eticketType: resolveKuaishouIndustryEticketType(voucher),
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 buildKuaishouIndustryShopNameMap(): Map<string, string> {
const map = new Map<string, string>()
const shops = listKuaishouIndustryShopConfigs(getKuaishouIndustrySourceConfig())
for (const shop of shops) {
const name = String(shop.customShopName || shop.shopName || '').trim()
if (!name) {
continue
}
const sellerId = String(shop.sellerId || '').trim()
const shopId = String(shop.shopId || '').trim()
if (sellerId) {
map.set(sellerId, name)
}
if (shopId && shopId !== sellerId) {
map.set(shopId, name)
}
}
return map
}
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 asJsonObject(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 []
}
}