Files
order_site/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts
T

598 lines
18 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { addHours, nowIso } from '../../utils/time.js'
import {
TASK_STATUS,
canRedeemKuaishouCloudClaimStatus,
isKuaishouCloudRedeemSettledStatus,
isKuaishouCloudRoleConfirmSettledStatus,
normalizeTaskStatus,
} from '../../domain/task-status.js'
import {
getKuaishouEticketSourceConfig,
listKuaishouEticketShopConfigs,
resolveKuaishouEticketShopConfig,
type KuaishouEticketShopConfig,
} from '../platforms/kuaishou-eticket/source-config-service.js'
import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js'
import { isKuaishouEticketMockTicketCode } from '../platforms/kuaishou-eticket/mock-ticket-service.js'
import {
dispatchKuaishouCloudFulfillmentTask,
maskCode,
normalizeKuaishouCloudFlow,
prepareKuaishouCloudFulfillmentTask,
refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud/index.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
import type { TaskRow } from '../../types/repository/rows.js'
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
type JsonObject = Record<string, any>
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
const context = await getClaimContext(token)
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
}
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
if (!ticketCode) {
throw createHttpError('请先粘贴快手小店核销码', {
statusCode: 400,
errorCode: 'claim_kuaishou_cloud_missing_ticket_code',
})
}
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode)
let shopId = String(flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : '')).trim()
let shopName = String(flow.consume.shopName || context.order.shop_name || '').trim()
let detailResult
if (mockTicketCode) {
detailResult = await queryKuaishouEticketConsumeDetail({
eTicketId: ticketCode,
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code,
})
} else {
const matched = await queryKuaishouEticketConsumeDetailWithShopFallback({
ticketCode,
shopId,
shopName,
fallbackShopName: String(context.order.shop_name || '').trim(),
})
detailResult = matched.detailResult
shopId = matched.shopConfig.shopId || shopId
shopName = matched.shopConfig.kshopName || shopName
}
if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) {
throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', {
statusCode: 409,
errorCode: detailResult.alreadyConsumed
? 'claim_kuaishou_cloud_ticket_already_consumed'
: 'claim_kuaishou_cloud_ticket_invalid',
})
}
const taskContext = parseTaskContext(context.task)
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
ticket: {
...flow.ticket,
code: detailResult.eTicketId || ticketCode,
status: 'verified',
capturedAt: flow.ticket.capturedAt || now,
capturedBy: flow.ticket.capturedBy || {
source: 'claim_page',
},
verifiedAt: now,
oid: String(detailResult.detail.oid || '').trim(),
formToken: String(detailResult.detail.formToken || '').trim(),
leftCount: Number(detailResult.detail.leftCount || 0) || 0,
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
},
consume: {
...flow.consume,
shopId,
},
},
}
if (isKuaishouCloudMockContext(nextContext)) {
const mockFlow = buildMockVerifiedKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment, now)
await updateTask(context.task.id, {
claim_token: context.claimToken.token,
claim_expires_at: context.claimToken.expired_at,
context_json: JSON.stringify({
...nextContext,
kuaishouCloudFulfillment: mockFlow,
}),
claimed_at: context.task.claimed_at || now,
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
})
} else {
const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
await prepareKuaishouCloudFulfillmentTask({
...context.task,
context_json: JSON.stringify(nextContext),
}, {
source: 'claim_page_ticket_verified',
actor: { source: 'claim_page' },
})
} else {
await updateTask(context.task.id, {
claim_token: context.claimToken.token,
claim_expires_at: context.claimToken.expired_at,
context_json: JSON.stringify(nextContext),
last_error: '',
updated_at: now,
})
}
if (!context.task.claimed_at) {
await updateTask(context.task.id, {
claimed_at: now,
updated_at: now,
})
}
}
await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', {
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
shopId,
shopName,
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
mock: mockTicketCode,
}, now)
return getKuaishouCloudClaimDetail(token)
}
export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
const normalized = String(filename || '').trim()
if (!ALLOWED_GUIDE_FILES.has(normalized)) {
throw createHttpError('指引图片不存在', {
statusCode: 404,
errorCode: 'claim_kuaishou_cloud_asset_not_found',
})
}
const filePath = path.resolve(KUAISHOU_CLOUD_GUIDE_DIR, normalized)
if (!filePath.startsWith(KUAISHOU_CLOUD_GUIDE_DIR) || !fs.existsSync(filePath)) {
throw createHttpError('指引图片不存在', {
statusCode: 404,
errorCode: 'claim_kuaishou_cloud_asset_not_found',
})
}
return filePath
}
export async function getKuaishouCloudClaimDetail(token: unknown) {
const context = await getClaimContext(token)
let task = context.task
if (
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
&& !isKuaishouCloudMockTask(task)
) {
task = await syncKuaishouCloudRoleInfo(task) || task
}
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
})
}
function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject {
const rawValue = task?.context_json
if (!rawValue) {
return {}
}
if (typeof rawValue === 'object') {
return rawValue
}
try {
return JSON.parse(String(rawValue || '{}'))
} catch {
return {}
}
}
async function queryKuaishouEticketConsumeDetailWithShopFallback({
ticketCode,
shopId,
shopName,
fallbackShopName = '',
}: {
ticketCode: string
shopId: string
shopName: string
fallbackShopName?: string
}) {
const eticketSource = getKuaishouEticketSourceConfig()
const shopConfigs = resolveKuaishouEticketDetailCandidateShops({
shopId,
shopName,
fallbackShopName,
})
if (shopConfigs.length === 0) {
throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
})
}
let lastResult: JsonObject | null = null
let lastError: unknown = null
for (const shopConfig of shopConfigs) {
try {
const detailResult = await queryKuaishouEticketConsumeDetail({
baseUrl: eticketSource.baseUrl,
cookie: shopConfig.cookie,
eTicketId: ticketCode,
})
lastResult = detailResult
if (detailResult.ok || detailResult.alreadyConsumed) {
return {
shopConfig,
detailResult,
}
}
} catch (error) {
lastError = error
}
}
if (lastResult) {
const fallbackShopConfig = shopConfigs[0]
if (!fallbackShopConfig) {
throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_shop_cookie_missing',
})
}
return {
shopConfig: fallbackShopConfig,
detailResult: lastResult,
}
}
throw createHttpError(lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_ticket_invalid',
})
}
function resolveKuaishouEticketDetailCandidateShops({
shopId,
shopName,
fallbackShopName = '',
}: {
shopId: string
shopName: string
fallbackShopName?: string
}) {
const candidates: KuaishouEticketShopConfig[] = []
const configuredShop = resolveKuaishouEticketShopConfig({
shopId,
shopName: shopName || fallbackShopName,
})
if (isUsableKuaishouEticketShopConfig(configuredShop)) {
candidates.push(configuredShop)
}
for (const shopConfig of listKuaishouEticketShopConfigs()) {
if (!isUsableKuaishouEticketShopConfig(shopConfig)) {
continue
}
if (candidates.some((item) => isSameKuaishouEticketShopConfig(item, shopConfig))) {
continue
}
candidates.push(shopConfig)
}
return candidates
}
function isUsableKuaishouEticketShopConfig(
shopConfig: KuaishouEticketShopConfig | null | undefined,
): shopConfig is KuaishouEticketShopConfig {
return Boolean(
shopConfig &&
shopConfig.enabled !== false &&
String(shopConfig.cookie || '').trim(),
)
}
function isSameKuaishouEticketShopConfig(
left: KuaishouEticketShopConfig,
right: KuaishouEticketShopConfig,
) {
const leftShopId = String(left.shopId || '').trim()
const rightShopId = String(right.shopId || '').trim()
if (leftShopId && rightShopId) {
return leftShopId === rightShopId
}
return String(left.kshopName || '').trim() === String(right.kshopName || '').trim()
}
export async function confirmKuaishouCloudClaimRole(token: unknown) {
const context = await getClaimContext(token)
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
}
if (isKuaishouCloudRoleConfirmSettledStatus(context.task.task_status)) {
return getKuaishouCloudClaimDetail(token)
}
const contextSource = parseTaskContext(context.task)
const mockMode = isKuaishouCloudMockContext(contextSource)
const refreshed = mockMode
? { task: context.task }
: await refreshKuaishouCloudTaskRoleInfo(context.task, {
source: 'claim_page_role_confirm',
actor: { source: 'claim_page' },
recordEvent: false,
forceProbe: true,
})
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
if (flow.ticket.status !== 'verified') {
throw createHttpError('请先验证核销码', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_ticket_not_verified',
})
}
if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) {
throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_role_not_ready',
})
}
await updateTask(context.task.id, {
task_status: TASK_STATUS.ROLE_CONFIRMED,
role_id: flow.binding.roleId,
role_name: flow.binding.roleName,
user_action_status: TASK_STATUS.ROLE_CONFIRMED,
role_confirmed_at: now,
last_error: '',
updated_at: now,
})
await createTaskEvent(context.task.id, 'kuaishou_cloud_role_confirmed', {
vnPhone: flow.binding.vnPhone,
roleName: flow.binding.roleName,
roleId: flow.binding.roleId,
}, now)
return getKuaishouCloudClaimDetail(token)
}
export async function redeemKuaishouCloudClaim(token: unknown) {
const context = await getClaimContext(token)
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
}
const currentStatus = normalizeTaskStatus(context.task.task_status)
if (isKuaishouCloudRedeemSettledStatus(currentStatus)) {
return getKuaishouCloudClaimDetail(token)
}
if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) {
throw createHttpError('请先确认角色信息', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_role_not_confirmed',
})
}
const lockedTask = await updateTaskStatusIfCurrent(context.task.id, TASK_STATUS.ROLE_CONFIRMED, {
task_status: TASK_STATUS.REDEEMING,
user_action_status: 'not_required',
last_error: '',
updated_at: now,
})
if (!lockedTask) {
return getKuaishouCloudClaimDetail(token)
}
if (isKuaishouCloudMockTask(lockedTask)) {
await completeMockKuaishouCloudClaimTask(lockedTask, now)
return getKuaishouCloudClaimDetail(token)
}
try {
await dispatchKuaishouCloudFulfillmentTask(lockedTask, {
source: 'claim_page_redeem',
actor: { source: 'claim_page' },
autoFinalize: true,
})
} catch (error) {
const latestTask = await getTaskById(lockedTask.id)
if (latestTask && normalizeTaskStatus(latestTask.task_status) !== TASK_STATUS.REDEEMING) {
return getKuaishouCloudClaimDetail(token)
}
const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理'
await updateTask(lockedTask.id, {
task_status: TASK_STATUS.MANUAL_REVIEW,
user_action_status: 'not_required',
last_error: message,
result_code: 'kuaishou_cloud_redeem_failed',
result_message: message,
updated_at: nowIso(),
})
await createTaskEvent(lockedTask.id, 'kuaishou_cloud_redeem_failed', {
source: 'claim_page_redeem',
errorMessage: message,
}, nowIso())
throw error
}
return getKuaishouCloudClaimDetail(token)
}
function isKuaishouCloudMockTask(task: Partial<TaskRow> | null | undefined) {
return isKuaishouCloudMockContext(parseTaskContext(task))
}
function isKuaishouCloudMockContext(context: JsonObject = {}) {
const flow = context.kuaishouCloudFulfillment
&& typeof context.kuaishouCloudFulfillment === 'object'
? context.kuaishouCloudFulfillment
: {}
const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock
return Boolean(mock && typeof mock === 'object' && mock.enabled === true)
}
function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) {
const flow = normalizeKuaishouCloudFlow(value)
const source = flow as JsonObject
const bindUrl = flow.binding.bindUrl
|| `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}`
const vnPhone = flow.binding.vnPhone || '13800000000'
const roleName = flow.binding.roleName || flow.role.name || '测试角色'
const roleId = flow.binding.roleId || flow.role.rid || '10001'
return {
...flow,
mock: {
...(source.mock && typeof source.mock === 'object' ? source.mock : {}),
enabled: true,
},
binding: {
...flow.binding,
prepareStatus: 'ready',
vnId: flow.binding.vnId || 900001,
vnPhone,
bindUrl,
bindPreparedAt: flow.binding.bindPreparedAt || timestamp,
bindExpiresAt: flow.binding.bindExpiresAt || addHours(timestamp, 24),
roleName,
roleId,
},
role: {
...flow.role,
status: 'ready',
name: roleName,
rid: roleId,
refreshedAt: flow.role.refreshedAt || timestamp,
errorMessage: '',
rawInfo: flow.role.rawInfo || {
mock: true,
},
},
}
}
async function completeMockKuaishouCloudClaimTask(task: TaskRow, timestamp: string) {
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const source = flow as JsonObject
const nextFlow = {
...flow,
mock: {
...(source.mock && typeof source.mock === 'object' ? source.mock : {}),
enabled: true,
},
dispatch: {
...flow.dispatch,
status: 'success',
dispatchAt: timestamp,
dispatchBy: {
source: 'claim_page_mock',
},
note: '开发 mock 已模拟发货成功',
items: flow.deliveryItems,
},
returnNumber: {
...flow.returnNumber,
status: 'success',
returnedAt: timestamp,
returnedBy: {
source: 'claim_page_mock',
},
},
consume: {
...flow.consume,
status: 'success',
consumedAt: timestamp,
errorMessage: '',
},
}
await updateTask(task.id, {
task_status: TASK_STATUS.COMPLETED,
delivery_status: 'success',
result_code: 'mock_success',
result_message: '开发 mock 已模拟兑换成功',
user_action_status: 'not_required',
last_error: '',
context_json: JSON.stringify({
...taskContext,
kuaishouCloudFulfillment: nextFlow,
}),
redeemed_at: timestamp,
updated_at: timestamp,
})
await createTaskEvent(task.id, 'kuaishou_cloud_mock_redeemed', {
source: 'claim_page_mock',
deliveryItems: flow.deliveryItems,
}, timestamp)
}