移除快手小店以及 api

This commit is contained in:
yml2213
2026-07-09 21:03:39 +08:00
parent a075b8acf7
commit ffbc19a9db
38 changed files with 110 additions and 2724 deletions
@@ -4,7 +4,6 @@ import { requireAdminRoles } from "./session.js";
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
import kuaishouIndustryRouter from "./platform-config/kuaishou-industry.js";
import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js";
import notificationsRouter from "./platform-config/notifications.js";
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
@@ -14,7 +13,6 @@ const router = Router();
router.use("/platform-config", requireAdminRoles(["admin"]));
router.use("/platform-config", notificationsRouter);
router.use("/platform-config", kuaishouIndustryRouter);
router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", kuaishouFeifeiRouter);
router.use("/platform-config", ninetyoneRouter);
router.use("/platform-config", fulfillmentRoutingRouter);
@@ -1,153 +0,0 @@
import { Router } from "express";
import {
consumeAdminKuaishouEticket,
getAdminKuaishouEticketSourceConfig,
queryAdminKuaishouEticketDetail,
queryAdminKuaishouEticketShopInfo,
updateAdminKuaishouEticketSourceConfig,
} from "../../../services/admin/platform-config/kuaishou-eticket-service.js";
import type {
AdminKuaishouEticketConsumeRouteBody,
AdminKuaishouEticketDetailQueryRouteBody,
AdminKuaishouEticketShopInfoRouteBody,
AdminKuaishouEticketSourceConfigRouteBody,
} from "../../../types/admin/route-inputs.js";
import { createJsonHandler } from "../session.js";
import type { JsonRecord } from "../../../types/json.js";
const router = Router();
router.get(
"/kuaishou-eticket-source",
createJsonHandler(() => getAdminKuaishouEticketSourceConfig(), {
successMessage: "ok",
errorMessage: "读取快手小店核销配置失败",
scope: "[admin/platform-config/kuaishou-eticket-source]",
})
);
router.post(
"/kuaishou-eticket-source",
createJsonHandler(
(req) =>
updateAdminKuaishouEticketSourceConfig(
req.body as AdminKuaishouEticketSourceConfigRouteBody
),
{
successMessage: "快手小店核销配置已保存",
errorMessage: "保存快手小店核销配置失败",
scope: "[admin/platform-config/kuaishou-eticket-source]",
audit: (_req, data) => {
const result = data as JsonRecord;
return {
action: "platform_kuaishou_eticket_source_updated",
targetType: "platform_config",
targetId: "kuaishou_eticket_source",
data: {
filePath: String(result.filePath || "").trim(),
enabled: Boolean(result.source?.enabled),
shopCount: Array.isArray(result.source?.shops)
? result.source.shops.length
: 0,
},
};
},
}
)
);
router.post(
"/kuaishou-eticket/query-detail",
createJsonHandler(
(req) =>
queryAdminKuaishouEticketDetail(
req.body as AdminKuaishouEticketDetailQueryRouteBody
),
{
successMessage: "快手小店核销信息查询成功",
errorMessage: "查询快手小店核销信息失败",
scope: "[admin/platform-config/kuaishou-eticket/query-detail]",
audit: (req, data) => {
const body = req.body as AdminKuaishouEticketDetailQueryRouteBody;
const result = data as JsonRecord;
return {
action: "platform_kuaishou_eticket_query_detail",
targetType: "platform_config",
targetId:
String(result.eTicketId || body.eTicketId || "").trim() ||
"kuaishou_eticket",
data: {
result: Number(result.result || 0),
ok: Boolean(result.ok),
alreadyConsumed: Boolean(result.alreadyConsumed),
},
};
},
}
)
);
router.post(
"/kuaishou-eticket/query-shop-info",
createJsonHandler(
(req) =>
queryAdminKuaishouEticketShopInfo(
req.body as AdminKuaishouEticketShopInfoRouteBody
),
{
successMessage: "快手小店店铺信息查询成功",
errorMessage: "查询快手小店店铺信息失败",
scope: "[admin/platform-config/kuaishou-eticket/query-shop-info]",
audit: (req, data) => {
const body = req.body as AdminKuaishouEticketShopInfoRouteBody;
const result = data as JsonRecord;
return {
action: "platform_kuaishou_eticket_query_shop_info",
targetType: "platform_config",
targetId:
String(result.shop?.shopId || body.shopId || "").trim() ||
"kuaishou_eticket_shop_info",
data: {
result: Number(result.result || 0),
ok: Boolean(result.ok),
kshopName: String(result.shop?.kshopName || "").trim(),
},
};
},
}
)
);
router.post(
"/kuaishou-eticket/consume",
createJsonHandler(
(req) =>
consumeAdminKuaishouEticket(
req.body as AdminKuaishouEticketConsumeRouteBody
),
{
successMessage: "快手小店核销请求已执行",
errorMessage: "执行快手小店核销失败",
scope: "[admin/platform-config/kuaishou-eticket/consume]",
audit: (req, data) => {
const body = req.body as AdminKuaishouEticketConsumeRouteBody;
const result = data as JsonRecord;
return {
action: "platform_kuaishou_eticket_consume",
targetType: "platform_config",
targetId:
String(result.eTicketId || body.eTicketId || "").trim() ||
"kuaishou_eticket",
data: {
result: Number(result.result || 0),
consumed: Boolean(result.consumed),
alreadyConsumed: Boolean(result.alreadyConsumed),
},
};
},
}
)
);
export default router;
-4
View File
@@ -14,7 +14,6 @@ import {
} from '../../services/admin/write/kuaishou-cloud-actions.js'
import { createJsonHandler, requireAdminRoles } from './session.js'
import type {
AdminTaskKuaishouCloudDispatchRouteBody,
AdminTaskKuaishouIndustryConsumeRouteBody,
AdminTaskRouteParams,
AdminTaskRouteQuery,
@@ -108,7 +107,6 @@ router.post(
(req) =>
dispatchAdminTaskKuaishouCloudFulfillment(
getTaskId(req),
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
req.adminSession || null,
),
{
@@ -117,7 +115,6 @@ router.post(
scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]',
audit: (req, data) => {
const result = data as AdminTaskActionResponse
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody
return {
action: 'task_kuaishou_cloud_dispatch',
@@ -128,7 +125,6 @@ router.post(
taskNo: result.task.taskNo,
status: result.task.status,
deliveryStatus: result.task.deliveryStatus,
ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()),
},
}
},
+1 -21
View File
@@ -4,12 +4,10 @@ import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/r
import {
confirmKuaishouCloudClaimRole,
getKuaishouCloudClaimDetail,
getKuaishouCloudClaimGuideAssetPath,
rebindKuaishouCloudClaimRole,
redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket,
} from '../services/claim/kuaishou-cloud-claim-service.js'
import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js'
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
const router = Router()
const claimReadRateLimit = createRateLimitMiddleware({
@@ -34,16 +32,6 @@ router.get(
}),
)
router.post(
'/:token/kuaishou-cloud/verify-ticket',
claimWriteRateLimit,
createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), {
successMessage: '核销码提交并核销成功',
errorMessage: '提交核销码失败',
scope: '[claims/:token/kuaishou-cloud/verify-ticket]',
}),
)
router.post(
'/:token/kuaishou-cloud/confirm-role',
claimWriteRateLimit,
@@ -74,14 +62,6 @@ router.post(
}),
)
router.get(
'/assets/kuaishou-cloud/:filename',
createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), {
errorMessage: '读取指引图片失败',
scope: '[claims/assets/kuaishou-cloud/:filename]',
}),
)
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
@@ -6,7 +6,6 @@ import {
isPlainObject,
pickFirstNonEmpty,
resolveCloudtentaclesAdminContext,
resolveKuaishouEticketAdminContext,
} from './context.js'
test('pickFirstNonEmpty returns first trimmed non-empty string value', () => {
@@ -21,52 +20,6 @@ test('isPlainObject only accepts non-array objects', () => {
assert.equal(isPlainObject('x'), false)
})
test('resolveKuaishouEticketAdminContext prefers explicit payload values', () => {
const context = resolveKuaishouEticketAdminContext(
{
baseUrl: ' https://override.example.com ',
shopId: ' shop-2 ',
cookie: ' cookie-2 ',
},
{
savedSource: { baseUrl: 'https://saved.example.com' },
findShopConfig(requestedShopId) {
assert.equal(requestedShopId, 'shop-2')
return { shopId: 'shop-2', cookie: 'saved-cookie-2' }
},
getFirstAvailableShop() {
throw new Error('should not use first available shop')
},
},
)
assert.deepEqual(context, {
baseUrl: 'https://override.example.com',
cookie: 'cookie-2',
shopId: 'shop-2',
shop: { shopId: 'shop-2', cookie: 'saved-cookie-2' },
})
})
test('resolveKuaishouEticketAdminContext falls back to first available shop', () => {
const context = resolveKuaishouEticketAdminContext(
{},
{
savedSource: { baseUrl: ' https://saved.example.com ' },
getFirstAvailableShop() {
return { shopId: 'shop-1', cookie: 'cookie-1' }
},
},
)
assert.deepEqual(context, {
baseUrl: 'https://saved.example.com',
cookie: 'cookie-1',
shopId: 'shop-1',
shop: { shopId: 'shop-1', cookie: 'cookie-1' },
})
})
test('resolveCloudtentaclesAdminContext merges payload source and persisted session', () => {
const context = resolveCloudtentaclesAdminContext(
{
@@ -15,32 +15,6 @@ export function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function resolveKuaishouEticketAdminContext(payload: JsonObject = {}, options: JsonObject = {}) {
const savedSource = options.savedSource || {};
const findShopConfig =
(options.findShopConfig || (() => null)) as (shopId: string, source: JsonObject) => unknown;
const getFirstAvailableShop =
(options.getFirstAvailableShop || (() => null)) as (source: JsonObject) => unknown;
const defaultBaseUrl = String(
options.defaultBaseUrl || "https://s.kwaixiaodian.com"
).trim();
const requestedShopId = String(payload.shopId || "").trim();
const configuredShop = (requestedShopId
? findShopConfig(requestedShopId, savedSource)
: getFirstAvailableShop(savedSource)) as JsonObject | null;
return {
baseUrl: pickFirstNonEmpty([
payload.baseUrl,
savedSource.baseUrl,
defaultBaseUrl,
]),
cookie: pickFirstNonEmpty([payload.cookie, configuredShop?.cookie]),
shopId: pickFirstNonEmpty([requestedShopId, configuredShop?.shopId]),
shop: configuredShop,
};
}
import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js";
import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js";
import {
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict'
import {
mapAdminCloudtentaclesSession,
mapAdminKuaishouEticketSourceConfig,
maskPhone,
maskSecret,
} from './mappers.js'
@@ -20,30 +19,6 @@ test('maskPhone hides middle digits for mobile numbers', () => {
assert.equal(maskPhone(''), '')
})
test('mapAdminKuaishouEticketSourceConfig maps shops and masks cookies', () => {
assert.deepEqual(
mapAdminKuaishouEticketSourceConfig(
{ enabled: true, baseUrl: ' https://s.kwaixiaodian.com ' },
[{ shopId: ' 1 ', kshopName: ' A店 ', cookie: 'cookie-abcdef123456', enabled: true }],
),
{
enabled: true,
baseUrl: 'https://s.kwaixiaodian.com',
shops: [
{
shopId: '1',
kshopName: 'A店',
cookie: 'cookie-abcdef123456',
cookieMasked: 'cookie****123456',
hasCookie: true,
userAvatar: '',
enabled: true,
},
],
},
)
})
test('mapAdminCloudtentaclesSession derives masked fields and token presence', () => {
assert.deepEqual(
mapAdminCloudtentaclesSession({
@@ -17,26 +17,6 @@ export function maskPhone(value: unknown) {
return maskPhoneValue(value, { maskShort: false });
}
export function mapAdminKuaishouEticketShopItem(item: JsonObject = {}) {
return {
shopId: String(item.shopId || "").trim(),
kshopName: String(item.kshopName || "").trim(),
cookie: String(item.cookie || "").trim(),
cookieMasked: maskSecret(item.cookie),
hasCookie: Boolean(String(item.cookie || "").trim()),
userAvatar: String(item.userAvatar || "").trim(),
enabled: item.enabled !== false,
};
}
export function mapAdminKuaishouEticketSourceConfig(config: JsonObject = {}, shops: JsonObject[] = []) {
return {
enabled: config.enabled !== false,
baseUrl: String(config.baseUrl || "").trim(),
shops: shops.map((item) => mapAdminKuaishouEticketShopItem(item)),
};
}
export function mapAdminCloudtentaclesSourceConfig(config: JsonObject = {}) {
return {
key: String(config.key || "").trim(),
@@ -1,80 +0,0 @@
import {
consumeKuaishouEticket,
queryKuaishouEticketConsumeDetail,
} from '../../platforms/kuaishou-eticket/consume-service.js'
import {
findKuaishouEticketShopConfig,
getFirstAvailableKuaishouEticketShop,
getKuaishouEticketSourceConfig,
getKuaishouEticketSourceFilePath,
listKuaishouEticketShopConfigs,
saveKuaishouEticketSourceConfig,
} from '../../platforms/kuaishou-eticket/source-config-service.js'
import { queryKuaishouEticketStableInfo } from '../../platforms/kuaishou-eticket/info-service.js'
import { resolveKuaishouEticketAdminContext } from './cloudtentacles/context.js'
import { mapAdminKuaishouEticketSourceConfig } from './cloudtentacles/mappers.js'
type JsonObject = Record<string, any>
function resolveAdminKuaishouEticketContext(payload: JsonObject = {}) {
return resolveKuaishouEticketAdminContext(payload, {
savedSource: getKuaishouEticketSourceConfig(),
findShopConfig: findKuaishouEticketShopConfig,
getFirstAvailableShop: getFirstAvailableKuaishouEticketShop,
})
}
export function getAdminKuaishouEticketSourceConfig() {
const config = getKuaishouEticketSourceConfig()
return {
filePath: getKuaishouEticketSourceFilePath(),
source: mapAdminKuaishouEticketSourceConfig(config, listKuaishouEticketShopConfigs(config)),
}
}
export function updateAdminKuaishouEticketSourceConfig(payload: JsonObject = {}) {
const saved = saveKuaishouEticketSourceConfig({
enabled: payload.enabled !== false,
baseUrl: String(payload.baseUrl || '').trim() || 'https://s.kwaixiaodian.com',
shops: Array.isArray(payload.shops) ? payload.shops : [],
})
return {
filePath: getKuaishouEticketSourceFilePath(),
source: mapAdminKuaishouEticketSourceConfig(saved, listKuaishouEticketShopConfigs(saved)),
}
}
export async function queryAdminKuaishouEticketDetail(payload: JsonObject = {}) {
const context = resolveAdminKuaishouEticketContext(payload)
return queryKuaishouEticketConsumeDetail({
baseUrl: context.baseUrl,
cookie: context.cookie,
eTicketId: String(payload.eTicketId || '').trim(),
})
}
export async function queryAdminKuaishouEticketShopInfo(payload: JsonObject = {}) {
const context = resolveAdminKuaishouEticketContext(payload)
return queryKuaishouEticketStableInfo({
baseUrl: context.baseUrl,
cookie: context.cookie,
})
}
export async function consumeAdminKuaishouEticket(payload: JsonObject = {}) {
const context = resolveAdminKuaishouEticketContext(payload)
return consumeKuaishouEticket({
baseUrl: context.baseUrl,
cookie: context.cookie,
eTicketId: String(payload.eTicketId || '').trim(),
oid: String(payload.oid || '').trim(),
formToken: String(payload.formToken || '').trim(),
num: payload.num,
storeId: String(payload.storeId || '').trim(),
})
}
@@ -17,18 +17,12 @@ import {
backCloudtentaclesVirtualNumber,
getCloudtentaclesBindInfo,
} from '../../platforms/cloudtentacles/virtual-number-service.js'
import { consumeKuaishouEticket } from '../../platforms/kuaishou-eticket/consume-service.js'
import { isKuaishouEticketMockTicketCode } from '../../platforms/kuaishou-eticket/mock-ticket-service.js'
import { resendKuaishouIndustryVoucherSendCallback } from '../../platforms/kuaishou-industry/send-code-service.js'
import { consumeKuaishouIndustryVoucher } from '../../platforms/kuaishou-industry/voucher-service.js'
import {
attachKuaishouIndustryVoucherToTask,
bindKuaishouIndustryVouchersToOrderTasks,
} from '../../platforms/kuaishou-industry/voucher-binding-service.js'
import {
getKuaishouEticketSourceConfig,
resolveKuaishouEticketShopConfig,
} from '../../platforms/kuaishou-eticket/source-config-service.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
@@ -58,7 +52,6 @@ import type {
AdminViewerSessionInput,
} from '../../../types/admin/read-inputs.js'
import type {
AdminTaskKuaishouCloudDispatchInput,
AdminTaskKuaishouIndustryConsumeInput,
} from '../../../types/admin/write-inputs.js'
import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js'
@@ -246,7 +239,6 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(
export async function dispatchAdminTaskKuaishouCloudFulfillment(
taskId: AdminEntityIdInput,
payload: AdminTaskKuaishouCloudDispatchInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
@@ -281,11 +273,15 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
})
}
const ticketCode = String(payload.ticketCode || '').trim()
const persistedTicketCode = String(flow.ticket.code || '').trim()
const voucherContext = normalizeAdminTaskIndustryVoucherContext(taskContext.kuaishouIndustryVoucher)
const industryVoucherCode = String(
voucherContext.voucherCode || voucherContext.eticketId || '',
).trim()
const resolvedTicketCode = persistedTicketCode || industryVoucherCode
if (!persistedTicketCode && !ticketCode) {
throw createHttpError('客户还没有在领取页提交核销码,暂时不能直接发货', {
if (!resolvedTicketCode) {
throw createHttpError('旧快手小店核销流程已停用,请改用行业电子凭证处理', {
statusCode: 409,
errorCode: 'admin_task_kuaishou_cloud_missing_ticket_code',
})
@@ -322,16 +318,9 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
...syncedFlow,
ticket: {
...syncedFlow.ticket,
code: ticketCode || persistedTicketCode,
capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt,
capturedBy:
ticketCode && session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: syncedFlow.ticket.capturedBy,
code: resolvedTicketCode,
capturedAt: resolvedTicketCode ? (syncedFlow.ticket.capturedAt || now) : syncedFlow.ticket.capturedAt,
capturedBy: syncedFlow.ticket.capturedBy,
},
dispatch: {
...syncedFlow.dispatch,
@@ -366,7 +355,7 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment(
task.id,
'kuaishou_cloud_dispatched',
{
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
ticketCodeMasked: maskCode(resolvedTicketCode),
skuId: syncedFlow.binding.skuId,
vnId: syncedFlow.binding.vnId,
vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone),
@@ -548,77 +537,18 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
id: flow.binding.vnId,
})
const order = await getOrderById(task.order_id)
const ticketCode = String(flow.ticket.code || '').trim()
const shopId = String(flow.consume.shopId || order?.shop_id || '').trim()
const shopName = String(flow.consume.shopName || order?.shop_name || '').trim()
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode)
const eticketSource = getKuaishouEticketSourceConfig()
const shopConfig = resolveKuaishouEticketShopConfig({
shopId,
shopName,
})
let consumeStatus = 'pending'
let consumeErrorMessage = ''
let consumedAt = null
let nextTaskStatus = 'completed'
let nextResultCode = 'kuaishou_cloud_completed'
let nextResultMessage = 'cloudtentacles 发货、退号并完成快手核销'
const consumeAlreadyCompleted = flow.consume.status === 'success'
if (consumeAlreadyCompleted) {
consumeStatus = 'success'
consumedAt = flow.consume.consumedAt || now
} else if (!order) {
consumeStatus = 'failed'
consumeErrorMessage = '任务关联订单不存在,无法执行快手核销'
} else if (!ticketCode) {
consumeStatus = 'failed'
consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销'
} else if (mockTicketCode) {
const consumeResult = await consumeKuaishouEticket({
eTicketId: ticketCode,
oid: String(flow.ticket.oid || '').trim(),
formToken: String(flow.ticket.formToken || '').trim(),
})
consumeStatus = consumeResult.consumed ? 'success' : 'failed'
consumedAt = consumeResult.consumed ? now : null
consumeErrorMessage = String(consumeResult.errorMessage || '').trim()
} else if (
!shopConfig ||
shopConfig.enabled === false ||
!String(shopConfig.cookie || '').trim()
) {
consumeStatus = 'failed'
consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销'
} else {
try {
const consumeResult = await consumeKuaishouEticket({
baseUrl: eticketSource.baseUrl,
cookie: shopConfig.cookie,
eTicketId: ticketCode,
oid: String(flow.ticket.oid || '').trim(),
formToken: String(flow.ticket.formToken || '').trim(),
})
if (consumeResult.consumed) {
consumeStatus = 'success'
consumedAt = now
} else {
consumeStatus = 'failed'
consumeErrorMessage = String(consumeResult.errorMessage || '快手核销失败').trim()
}
} catch (error) {
consumeStatus = 'failed'
consumeErrorMessage = error instanceof Error ? error.message : '快手核销失败'
}
}
if (consumeStatus !== 'success') {
nextTaskStatus = 'manual_review'
nextResultCode = 'kuaishou_cloud_consume_failed'
nextResultMessage = consumeErrorMessage || '号码已退还,但快手核销未完成,请人工处理'
}
const consumeStatus = consumeAlreadyCompleted ? 'success' : 'skipped'
let consumeErrorMessage = ''
const consumedAt = consumeAlreadyCompleted ? flow.consume.consumedAt || now : null
const nextTaskStatus = 'completed'
const nextResultCode = consumeAlreadyCompleted
? 'kuaishou_cloud_completed'
: 'kuaishou_cloud_completed_without_eticket_consume'
const nextResultMessage = consumeAlreadyCompleted
? 'cloudtentacles 发货、退号并完成电子凭证核销'
: 'cloudtentacles 发货、退号并收口'
const nextContext = {
...taskContext,
@@ -639,8 +569,8 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
consume: {
...flow.consume,
status: consumeStatus,
shopId: shopId || flow.consume.shopId,
shopName,
shopId: flow.consume.shopId,
shopName: flow.consume.shopName,
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
consumedAt,
errorMessage: consumeErrorMessage,
@@ -653,7 +583,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
delivery_status: 'delivered',
result_code: nextResultCode,
result_message: nextResultMessage,
redeemed_at: consumeStatus === 'success' ? now : task.redeemed_at,
redeemed_at: now,
last_error: consumeErrorMessage,
context_json: JSON.stringify(nextContext),
updated_at: now,
@@ -669,18 +599,19 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment(
now,
)
const consumeEventType = consumeAlreadyCompleted
? 'kuaishou_cloud_consume_already_completed'
: 'kuaishou_cloud_consume_skipped'
await createTaskEvent(
task.id,
consumeAlreadyCompleted
? 'kuaishou_cloud_consume_already_completed'
: consumeStatus === 'success'
? 'kuaishou_cloud_consumed'
: 'kuaishou_cloud_consume_failed',
consumeEventType,
{
ticketCodeMasked: maskCode(ticketCode),
shopId,
shopName,
shopId: flow.consume.shopId,
shopName: flow.consume.shopName,
consumeStatus,
consumeMode: 'legacy_writeoff_disabled',
errorMessage: consumeErrorMessage,
},
now,
@@ -961,7 +892,7 @@ async function getRequiredIndustryVoucherForTask(task: TaskRow): Promise<Kuaisho
}
const taskContext = parseTaskContext(task)
const voucherContext = taskContext.kuaishouIndustryVoucher || {}
const voucherContext = normalizeAdminTaskIndustryVoucherContext(taskContext.kuaishouIndustryVoucher)
const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim()
if (voucherCode) {
const voucher = await findKuaishouIndustryVoucherByCode(voucherCode, task.platform_order_id)
@@ -1000,6 +931,12 @@ async function getRequiredIndustryVoucherForTask(task: TaskRow): Promise<Kuaisho
})
}
function normalizeAdminTaskIndustryVoucherContext(value: unknown): Record<string, any> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, any>
: {}
}
async function resolveIndustryVouchersForOrder(platformOrderId: string): Promise<KuaishouIndustryVoucherRow[]> {
const normalizedOid = String(platformOrderId || '').trim()
if (!normalizedOid) {
@@ -11,8 +11,6 @@ import { buildClaimUrl } from './claim-service.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED])
export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
type JsonObject = Record<string, any>
type ClaimContext = {
claimToken: ClaimTokenRow
@@ -355,11 +353,7 @@ export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow)
flowType: 'kuaishou_cloud',
shopId: String(order?.shop_id || '').trim(),
shopName: String(order?.shop_name || '').trim(),
guideImages: [
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`,
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`,
`${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`,
],
guideImages: [],
ticket: {
code: String(ticket.code || '').trim(),
status: String(ticket.status || 'pending').trim() || 'pending',
@@ -1,7 +1,3 @@
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'
@@ -13,22 +9,10 @@ import {
isKuaishouCloudRoleConfirmSettledStatus,
normalizeTaskStatus,
} from '../../domain/task-status.js'
import {
getKuaishouEticketSourceConfig,
listKuaishouEticketShopConfigs,
resolveKuaishouEticketShopConfig,
type KuaishouEticketShopConfig,
} from '../platforms/kuaishou-eticket/source-config-service.js'
import {
consumeKuaishouEticket,
queryKuaishouEticketConsumeDetail,
} from '../platforms/kuaishou-eticket/consume-service.js'
import { isKuaishouEticketMockTicketCode } from '../platforms/kuaishou-eticket/mock-ticket-service.js'
import {
dispatchKuaishouCloudFulfillmentTask,
hasKuaishouCloudCustomerRole,
hasKuaishouCloudDefaultRoleSnapshot,
maskCode,
normalizeKuaishouCloudFlow,
prepareKuaishouCloudFulfillmentTask,
rebindKuaishouCloudTaskRole,
@@ -39,215 +23,9 @@ import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim
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>
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
const context = await getClaimContext(token)
const now = nowIso()
const executorKey = String(context.task.executor_key || '').trim()
if (executorKey === 'kuaishou-industry') {
return verifyIndustryVoucherTicket(context, now)
}
if (executorKey !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
})
}
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
if (hasUsableIndustryVoucher(parseTaskContext(context.task))) {
return verifyIndustryVoucherTicket(context, now)
}
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
if (!ticketCode) {
throw createHttpError('请先粘贴快手小店核销码', {
statusCode: 400,
errorCode: 'claim_kuaishou_cloud_missing_ticket_code',
})
}
const persistedTicketCode = String(flow.ticket.code || '').trim()
if (flow.consume.status === 'success') {
if (!persistedTicketCode || persistedTicketCode === ticketCode) {
return getKuaishouCloudClaimDetail(token)
}
throw createHttpError('当前领取链接已完成核销,不能更换核销码', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_ticket_already_consumed',
})
}
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
let shopCookie = ''
let consumeBaseUrl = ''
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
shopCookie = matched.shopConfig.cookie
consumeBaseUrl = getKuaishouEticketSourceConfig().baseUrl
}
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 consumePayload = {
baseUrl: consumeBaseUrl,
cookie: shopCookie,
eTicketId: detailResult.eTicketId || ticketCode,
oid: String(detailResult.detail.oid || '').trim(),
formToken: String(detailResult.detail.formToken || '').trim(),
num: Number(detailResult.detail.leftCount || 0) || 1,
}
const consumeResult = await consumeKuaishouEticket(consumePayload)
if (!consumeResult.consumed) {
throw createHttpError(consumeResult.errorMessage || '核销失败,请确认核销码状态后重试', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_consume_failed',
})
}
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,
status: 'success',
shopId,
shopName,
consumedAt: now,
errorMessage: '',
},
},
}
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)
const taskWithConsumedTicket = (await updateTask(context.task.id, {
claim_token: context.claimToken.token,
claim_expires_at: context.claimToken.expired_at,
context_json: JSON.stringify(nextContext),
claimed_at: context.task.claimed_at || now,
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
})) || {
...context.task,
context_json: JSON.stringify(nextContext),
claimed_at: context.task.claimed_at || now,
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
}
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
await prepareKuaishouCloudFulfillmentTask(taskWithConsumedTicket, {
source: 'claim_page_ticket_verified',
actor: { source: 'claim_page' },
})
}
}
await createTaskEvent(
context.task.id,
'kuaishou_cloud_ticket_verified',
{
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
shopId,
shopName,
goodsTitle: String(detailResult.goods?.itemTitle || '').trim(),
mock: mockTicketCode,
consumedAt: now,
consumeAlreadyCompleted: consumeResult.alreadyConsumed === true,
},
now,
)
await createTaskEvent(
context.task.id,
'kuaishou_cloud_consumed',
{
source: 'claim_page_ticket_submit',
ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode),
shopId,
shopName,
consumeStatus: 'success',
consumedAt: now,
mock: mockTicketCode,
},
now,
)
return getKuaishouCloudClaimDetail(token)
}
async function verifyIndustryVoucherTicket(
context: Awaited<ReturnType<typeof getClaimContext>>,
now: string,
@@ -363,26 +141,6 @@ async function verifyIndustryVoucherTicket(
return getKuaishouCloudClaimDetail(context.claimToken.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): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token)
let task = context.task
@@ -500,134 +258,6 @@ function normalizeIndustryVoucherContext(value: unknown): JsonObject {
}
}
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()
@@ -656,7 +286,7 @@ export async function confirmKuaishouCloudClaimRole(token: unknown) {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
if (flow.ticket.status !== 'verified') {
throw createHttpError('请先验证核销码', {
throw createHttpError('请先完成电子凭证确认', {
statusCode: 409,
errorCode: 'claim_kuaishou_cloud_ticket_not_verified',
})
@@ -45,7 +45,7 @@ async function preparePaidTask(
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: task.last_error || '领取链接已生成,等待客户提交核销码',
last_error: task.last_error || '领取链接已生成,等待电子凭证流程推进',
updated_at: now,
})
}
@@ -1,13 +1,9 @@
import { getOrderById } from "../../../repositories/order-repo.js";
import { createTaskEvent } from "../../../repositories/task-event-repo.js";
import { updateTask } from "../../../repositories/task-repo.js";
import { createHttpError } from "../../../utils/http.js";
import { nowIso } from "../../../utils/time.js";
import { TASK_STATUS, normalizeTaskStatus, type TaskStatus } from "../../../domain/task-status.js";
import {
notifyKuaishouCloudAssetNotEnough,
notifyKuaishouCloudConsumeFailed,
} from "../../notification/domain-notifications.js";
import { notifyKuaishouCloudAssetNotEnough } from "../../notification/domain-notifications.js";
import {
buyCloudtentaclesSku,
getCloudtentaclesAsset,
@@ -16,13 +12,7 @@ import {
} from "../../platforms/cloudtentacles/catalog-service.js";
import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js";
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js";
import {
getKuaishouEticketSourceConfig,
resolveKuaishouEticketShopConfig,
} from "../../platforms/kuaishou-eticket/source-config-service.js";
import {
isKuaishouCloudTask,
isIndustryEVoucherTask,
@@ -95,10 +85,19 @@ export async function dispatchKuaishouCloudFulfillmentTask(
);
}
const ticketCode = String(options.ticketCode || "").trim();
const persistedTicketCode = String(flow.ticket.code || "").trim();
if (!persistedTicketCode && !ticketCode) {
throw createHttpError("客户还没有提交有效核销码,暂时不能继续兑换", {
const voucherContext = isPlainObject(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher
: {};
const industryVoucherCode = String(
voucherContext.voucherCode || voucherContext.eticketId || ""
).trim();
const hasIndustryVoucherForDispatch =
Boolean(industryVoucherCode) || isIndustryEVoucherTask(task);
const resolvedTicketCode = persistedTicketCode || industryVoucherCode;
if (!resolvedTicketCode && !hasIndustryVoucherForDispatch) {
throw createHttpError("旧快手小店核销流程已停用,请改用行业电子凭证处理", {
statusCode: 409,
errorCode: "kuaishou_cloud_missing_ticket_code",
});
@@ -173,9 +172,11 @@ export async function dispatchKuaishouCloudFulfillmentTask(
...syncedFlow,
ticket: {
...syncedFlow.ticket,
code: ticketCode || persistedTicketCode,
capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt,
capturedBy: ticketCode && actor ? actor : syncedFlow.ticket.capturedBy,
code: resolvedTicketCode,
capturedAt: resolvedTicketCode
? syncedFlow.ticket.capturedAt || now
: syncedFlow.ticket.capturedAt,
capturedBy: syncedFlow.ticket.capturedBy,
},
dispatch: {
...syncedFlow.dispatch,
@@ -222,7 +223,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
"kuaishou_cloud_dispatched",
{
source: String(options.source || "system").trim() || "system",
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
ticketCodeMasked: maskCode(resolvedTicketCode),
skuId: syncedFlow.binding.skuId,
vnId: syncedFlow.binding.vnId,
vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone),
@@ -785,34 +786,25 @@ export async function returnKuaishouCloudFulfillmentTask(
id: flow.binding.vnId,
});
const order = await getOrderById(task.order_id);
const ticketCode = String(flow.ticket.code || "").trim();
const shopId = String(flow.consume.shopId || order?.shop_id || "").trim();
const shopName = String(
flow.consume.shopName || order?.shop_name || ""
).trim();
const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode);
const eticketSource = getKuaishouEticketSourceConfig();
const shopConfig = resolveKuaishouEticketShopConfig({
shopId,
shopName,
});
let consumeStatus = "pending";
let consumeErrorMessage = "";
let consumedAt = null;
let nextTaskStatus: TaskStatus = TASK_STATUS.COMPLETED;
let nextResultCode = "kuaishou_cloud_completed";
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
const consumeAlreadyCompleted = flow.consume.status === "success";
const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext);
const isIndustryTask = isIndustryEVoucherTask(task);
const shouldConsumeIndustryVoucher = hasIndustryVoucher || isIndustryTask;
let nextResultMessage = shouldConsumeIndustryVoucher
? "cloudtentacles 发货、退号并完成电子凭证核销"
: "cloudtentacles 发货、退号并收口";
let industryVoucherContextPatch: JsonObject | null = null;
if (consumeAlreadyCompleted) {
consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now;
} else if (hasIndustryVoucher || isIndustryTask) {
} else if (shouldConsumeIndustryVoucher) {
const industryResult = hasIndustryVoucher
? await consumeKuaishouIndustryVouchersForTask(task, {
source: String(options.source || "system_auto_finalize").trim() || "system_auto_finalize",
@@ -844,59 +836,20 @@ export async function returnKuaishouCloudFulfillmentTask(
consumeErrorMessage =
industryResult.failed[0]?.errorMessage || "电子凭证核销回调失败,请人工处理";
}
} else if (!order) {
consumeStatus = "failed";
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
} else if (!ticketCode) {
consumeStatus = "failed";
consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销";
} else if (mockTicketCode) {
const consumeResult = await consumeKuaishouEticket({
eTicketId: ticketCode,
oid: String(flow.ticket.oid || "").trim(),
formToken: String(flow.ticket.formToken || "").trim(),
});
consumeStatus = consumeResult.consumed ? "success" : "failed";
consumedAt = consumeResult.consumed ? now : null;
consumeErrorMessage = String(consumeResult.errorMessage || "").trim();
} else if (
!shopConfig ||
shopConfig.enabled === false ||
!String(shopConfig.cookie || "").trim()
) {
consumeStatus = "failed";
consumeErrorMessage = "订单对应快手小店缺少可用 Cookie,无法执行快手核销";
} else {
try {
const consumeResult = await consumeKuaishouEticket({
baseUrl: eticketSource.baseUrl,
cookie: shopConfig.cookie,
eTicketId: ticketCode,
oid: String(flow.ticket.oid || "").trim(),
formToken: String(flow.ticket.formToken || "").trim(),
});
if (consumeResult.consumed) {
consumeStatus = "success";
consumedAt = now;
} else {
consumeStatus = "failed";
consumeErrorMessage = String(
consumeResult.errorMessage || "快手核销失败"
).trim();
}
} catch (error) {
consumeStatus = "failed";
consumeErrorMessage =
error instanceof Error ? error.message : "快手核销失败";
}
consumeStatus = "skipped";
consumeErrorMessage = "";
}
if (consumeStatus !== "success") {
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW;
nextResultCode = "kuaishou_cloud_consume_failed";
nextResultMessage =
consumeErrorMessage || "号码已退还,但快手核销未完成,请人工处理";
if (shouldConsumeIndustryVoucher) {
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW;
nextResultCode = "kuaishou_cloud_consume_failed";
nextResultMessage =
consumeErrorMessage || "号码已退还,但电子凭证核销未完成,请人工处理";
} else {
nextResultCode = "kuaishou_cloud_completed_without_eticket_consume";
}
}
const nextContext = {
@@ -922,8 +875,8 @@ export async function returnKuaishouCloudFulfillmentTask(
consume: {
...flow.consume,
status: consumeStatus,
shopId: shopId || flow.consume.shopId,
shopName,
shopId: flow.consume.shopId,
shopName: flow.consume.shopName,
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
consumedAt,
errorMessage: consumeErrorMessage,
@@ -936,7 +889,7 @@ export async function returnKuaishouCloudFulfillmentTask(
delivery_status: "delivered",
result_code: nextResultCode,
result_message: nextResultMessage,
redeemed_at: consumeStatus === "success" ? now : task.redeemed_at,
redeemed_at: consumeStatus === "failed" ? task.redeemed_at : now,
last_error: consumeErrorMessage,
context_json: JSON.stringify(nextContext),
updated_at: now,
@@ -954,36 +907,30 @@ export async function returnKuaishouCloudFulfillmentTask(
now
);
const consumeEventType = consumeAlreadyCompleted
? "kuaishou_cloud_consume_already_completed"
: consumeStatus === "success"
? "kuaishou_cloud_consumed"
: consumeStatus === "skipped"
? "kuaishou_cloud_consume_skipped"
: "kuaishou_cloud_consume_failed";
await createTaskEvent(
task.id,
consumeAlreadyCompleted
? "kuaishou_cloud_consume_already_completed"
: consumeStatus === "success"
? "kuaishou_cloud_consumed"
: "kuaishou_cloud_consume_failed",
consumeEventType,
{
source: String(options.source || "system").trim() || "system",
ticketCodeMasked: maskCode(ticketCode),
shopId,
shopName,
shopId: flow.consume.shopId,
shopName: flow.consume.shopName,
consumeStatus,
consumeMode: shouldConsumeIndustryVoucher ? "industry_voucher" : "legacy_writeoff_disabled",
errorMessage: consumeErrorMessage,
actor,
},
now
);
if (consumeStatus !== "success") {
await notifyKuaishouCloudConsumeFailed({
task: updatedTask,
order,
ticketCodeMasked: maskCode(ticketCode),
shopId,
shopName,
errorMessage: consumeErrorMessage,
});
}
return {
task: updatedTask,
flow: normalizeKuaishouCloudFlow(
@@ -48,14 +48,6 @@ type TaskNotificationPayload = {
source?: unknown
errorMessage?: unknown
}
type KuaishouCloudConsumeFailedPayload = {
task?: unknown
order?: unknown
ticketCodeMasked?: unknown
shopId?: unknown
shopName?: unknown
errorMessage?: unknown
}
type ClaimRedeemNeedsAttentionPayload = {
task?: unknown
order?: unknown
@@ -216,31 +208,6 @@ export function notifyKuaishouCloudBindUrlRefreshFailed({
})
}
export function notifyKuaishouCloudConsumeFailed({
task = {},
order = {},
ticketCodeMasked = '',
shopId = '',
shopName = '',
errorMessage = '',
}: KuaishouCloudConsumeFailedPayload = {}) {
const taskRecord = toRecord(task)
const orderRecord = toRecord(order)
return notifyInternalSafely({
title: '快手核销失败,需人工处理',
body: [
formatTaskLine(taskRecord),
`订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`,
`券码:${String(ticketCodeMasked || '').trim() || '-'}`,
`店铺:${String(shopName || shopId || '').trim() || '-'}`,
`原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`,
].join('\n'),
category: 'kuaishou_cloud_consume_failed',
cooldownKey: ['kuaishou_cloud_consume_failed', taskRecord.id || ''].join(':'),
})
}
export function notifyTaskAutoManualReview({
task = {},
reason = '',
@@ -1,121 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
consumeKuaishouEticket,
mapKuaishouEticketDetailResult,
normalizeKuaishouEticketApiResult,
queryKuaishouEticketConsumeDetail,
resolveKuaishouEticketConsumePayload,
} from './consume-service.js'
test('normalizeKuaishouEticketApiResult recognizes already consumed response', () => {
const result = normalizeKuaishouEticketApiResult({
result: 21,
error_msg: '券码已核销无法再次核销,请核实后重试',
requestId: 'abc',
})
assert.equal(result.ok, false)
assert.equal(result.result, 21)
assert.equal(result.errorMessage, '券码已核销无法再次核销,请核实后重试')
assert.equal(result.requestId, 'abc')
})
test('mapKuaishouEticketDetailResult maps detail payload fields', () => {
const result = mapKuaishouEticketDetailResult({
result: 1,
error_msg: '成功',
data: {
eTicket: {
eTicketId: 'F2E886B6CBF8E5F5',
oid: '2612200246413561',
formToken: '1777716306154',
leftCount: 1,
totalCount: 1,
status: 'CONSUME_ORDER_CREATED',
},
goods: {
itemTitle: '测试链接五排',
skuDesc: '测试1',
},
},
}, {
baseUrl: 'https://s.kwaixiaodian.com',
eTicketId: 'F2E886B6CBF8E5F5',
})
assert.equal(result.ok, true)
assert.equal(result.detail?.oid, '2612200246413561')
assert.equal(result.detail?.formToken, '1777716306154')
assert.equal(result.detail?.leftCount, 1)
assert.equal(result.goods?.itemTitle, '测试链接五排')
})
test('resolveKuaishouEticketConsumePayload falls back to detail result', () => {
const payload = resolveKuaishouEticketConsumePayload({
eTicketId: 'F2E886B6CBF8E5F5',
}, {
detail: {
eTicketId: 'F2E886B6CBF8E5F5',
oid: '2612200246413561',
formToken: '1777716306154',
leftCount: 2,
},
})
assert.deepEqual(payload, {
eTicketId: 'F2E886B6CBF8E5F5',
num: 2,
storeId: '0',
oid: '2612200246413561',
formToken: '1777716306154',
})
})
test('queryKuaishouEticketConsumeDetail accepts mock ticket code outside production', async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
const result = await queryKuaishouEticketConsumeDetail({
eTicketId: 'MOCK-CLAIM-001',
goodsTitle: '套装-浪漫天命',
})
assert.equal(result.ok, true)
assert.equal(result.eTicketId, 'MOCK-CLAIM-001')
assert.equal(result.detail?.leftCount, 1)
assert.equal(result.goods?.itemTitle, '套装-浪漫天命')
} finally {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
}
})
test('consumeKuaishouEticket consumes mock ticket code outside production', async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
try {
const result = await consumeKuaishouEticket({
eTicketId: 'MOCK-CLAIM-002',
oid: 'MOCK-OID-002',
formToken: 'MOCK-FORM-002',
})
assert.equal(result.consumed, true)
assert.equal(result.eTicketId, 'MOCK-CLAIM-002')
assert.equal(result.oid, 'MOCK-OID-002')
assert.equal(result.formToken, 'MOCK-FORM-002')
} finally {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
}
})
@@ -1,273 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { kuaishouEticketRequest } from './http-client.js'
import { resolveKuaishouEticketConfig } from './helpers.js'
import {
buildMockKuaishouEticketConsumeResult,
buildMockKuaishouEticketDetailResult,
isKuaishouEticketMockTicketCode,
} from './mock-ticket-service.js'
type JsonObject = Record<string, any>
type KuaishouEticketDetailPayload = {
eTicketId: string
oid: string
formToken: string
leftCount: number
[key: string]: unknown
}
type KuaishouEticketDetailResult = {
detail: KuaishouEticketDetailPayload | null
[key: string]: any
}
export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {}) {
if (isKuaishouEticketMockTicketCode(payload.eTicketId)) {
return buildMockKuaishouEticketDetailResult({
baseUrl: String(payload.baseUrl || '').trim(),
eTicketId: String(payload.eTicketId || '').trim(),
goodsTitle: String(payload.goodsTitle || '').trim(),
})
}
const context = resolveKuaishouEticketActionContext(payload)
const response = await kuaishouEticketRequest(context.detailPath, {
baseUrl: context.baseUrl,
cookie: context.cookie,
timeoutMs: context.timeoutMs,
body: {
eTicketId: context.eTicketId,
},
})
return mapKuaishouEticketDetailResult(response.payload, {
baseUrl: context.baseUrl,
eTicketId: context.eTicketId,
})
}
export async function consumeKuaishouEticket(payload: JsonObject = {}) {
if (isKuaishouEticketMockTicketCode(payload.eTicketId)) {
return buildMockKuaishouEticketConsumeResult({
baseUrl: String(payload.baseUrl || '').trim(),
eTicketId: String(payload.eTicketId || '').trim(),
oid: String(payload.oid || '').trim(),
formToken: String(payload.formToken || '').trim(),
num: payload.num,
storeId: String(payload.storeId || '').trim(),
})
}
const context = resolveKuaishouEticketActionContext(payload)
const detailResult = shouldResolveConsumeDetail(payload)
? await queryKuaishouEticketConsumeDetail({
baseUrl: context.baseUrl,
cookie: context.cookie,
eTicketId: context.eTicketId,
timeoutMs: context.timeoutMs,
})
: null
const consumePayload = resolveKuaishouEticketConsumePayload(payload, detailResult)
const response = await kuaishouEticketRequest(context.consumePath, {
baseUrl: context.baseUrl,
cookie: context.cookie,
timeoutMs: context.timeoutMs,
body: consumePayload,
})
return mapKuaishouEticketConsumeResult(response.payload, {
baseUrl: context.baseUrl,
eTicketId: consumePayload.eTicketId,
oid: consumePayload.oid,
formToken: consumePayload.formToken,
num: consumePayload.num,
storeId: consumePayload.storeId,
detailResult,
})
}
export function mapKuaishouEticketDetailResult(payload: JsonObject = {}, context: JsonObject = {}) {
const normalized = normalizeKuaishouEticketApiResult(payload)
const detail = mapKuaishouEticketDetailPayload(payload?.data?.eTicket)
const goods = mapKuaishouEticketGoodsPayload(payload?.data?.goods)
return {
baseUrl: String(context.baseUrl || '').trim(),
eTicketId: String(context.eTicketId || detail?.eTicketId || '').trim(),
ok: normalized.ok,
alreadyConsumed: normalized.result === 21,
result: normalized.result,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
serverTimestamp: normalized.serverTimestamp,
detail,
goods,
raw: payload && typeof payload === 'object' ? payload : {},
}
}
export function mapKuaishouEticketConsumeResult(payload: JsonObject = {}, context: JsonObject = {}) {
const normalized = normalizeKuaishouEticketApiResult(payload)
return {
baseUrl: String(context.baseUrl || '').trim(),
eTicketId: String(context.eTicketId || '').trim(),
oid: String(context.oid || '').trim(),
formToken: String(context.formToken || '').trim(),
num: normalizePositiveInteger(context.num, 1),
storeId: String(context.storeId || '0').trim() || '0',
ok: normalized.ok,
consumed: normalized.result === 1 || normalized.result === 21,
alreadyConsumed: normalized.result === 21,
result: normalized.result,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
serverTimestamp: normalized.serverTimestamp,
detail: context.detailResult?.detail || null,
raw: payload && typeof payload === 'object' ? payload : {},
}
}
export function resolveKuaishouEticketConsumePayload(
payload: JsonObject = {},
detailResult: KuaishouEticketDetailResult | null = null,
) {
const detail = detailResult?.detail || null
const eTicketId = pickFirstNonEmpty([payload.eTicketId, detail?.eTicketId])
const oid = pickFirstNonEmpty([payload.oid, detail?.oid])
const formToken = pickFirstNonEmpty([payload.formToken, detail?.formToken])
const storeId = pickFirstNonEmpty([payload.storeId, '0'])
const num = normalizePositiveInteger(payload.num, normalizePositiveInteger(detail?.leftCount, 1))
if (!eTicketId) {
throw createHttpError('请先填写 eTicketId', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_eticket_id',
})
}
if (!oid) {
throw createHttpError('缺少核销所需 oid,请先查询核销信息', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_oid',
})
}
if (!formToken) {
throw createHttpError('缺少核销所需 formToken,请先查询核销信息', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_form_token',
})
}
return {
eTicketId,
num,
storeId,
oid,
formToken,
}
}
export function normalizeKuaishouEticketApiResult(payload: JsonObject = {}) {
const result = Number(payload?.result || 0)
return {
ok: result === 1,
result,
errorMessage: String(payload?.error_msg || '').trim(),
requestId: String(payload?.requestId || '').trim(),
serverTimestamp: String(payload?.serverTimestamp || '').trim(),
}
}
export function mapKuaishouEticketDetailPayload(value: unknown): KuaishouEticketDetailPayload | null {
if (!isPlainObject(value)) {
return null
}
return {
uid: String(value.uid || '').trim(),
fulfillDetailId: String(value.fulfillDetailId || '').trim(),
sellerId: String(value.sellerId || '').trim(),
formToken: String(value.formToken || '').trim(),
validEndTime: String(value.validEndTime || '').trim(),
validStartTime: String(value.validStartTime || '').trim(),
leftReverseCount: normalizeInteger(value.leftReverseCount, 0),
eTicketId: String(value.eTicketId || '').trim(),
oid: String(value.oid || '').trim(),
totalCount: normalizeInteger(value.totalCount, 0),
leftCount: normalizeInteger(value.leftCount, 0),
status: String(value.status || '').trim(),
}
}
export function mapKuaishouEticketGoodsPayload(value: unknown) {
if (!isPlainObject(value)) {
return null
}
return {
itemId: String(value.itemId || '').trim(),
itemPicUrl: String(value.itemPicUrl || '').trim(),
itemTitle: String(value.itemTitle || '').trim(),
price: String(value.price || '').trim(),
skuDesc: String(value.skuDesc || '').trim(),
skuId: String(value.skuId || '').trim(),
}
}
function resolveKuaishouEticketActionContext(payload: JsonObject = {}) {
const config = resolveKuaishouEticketConfig(payload)
const eTicketId = String(payload.eTicketId || '').trim()
if (!config.cookie) {
throw createHttpError('请先配置快手小店 Cookie', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_cookie',
})
}
if (!eTicketId) {
throw createHttpError('请先填写 eTicketId', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_eticket_id',
})
}
return {
...config,
eTicketId,
}
}
function shouldResolveConsumeDetail(payload: JsonObject = {}): boolean {
return !String(payload.oid || '').trim() || !String(payload.formToken || '').trim()
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function normalizeInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,106 +0,0 @@
const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com'
const DEFAULT_REFERER_PATH = '/zone/industry/voucher/verify-list'
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
export type KuaishouEticketConfigInput = {
enabled?: boolean
baseUrl?: string
cookie?: string
timeoutMs?: number | string
infoPath?: string
detailPath?: string
consumePath?: string
refererPath?: string
userAgent?: string
}
export type KuaishouEticketConfig = {
enabled: boolean
baseUrl: string
cookie: string
timeoutMs: number
infoPath: string
detailPath: string
consumePath: string
refererPath: string
userAgent: string
}
type HeaderInput = {
baseUrl?: string
refererPath?: string
userAgent?: string
cookie?: string
contentType?: string
extra?: Record<string, unknown>
}
export function resolveKuaishouEticketConfig(overrides: KuaishouEticketConfigInput = {}): KuaishouEticketConfig {
return {
enabled: overrides.enabled !== false,
baseUrl: normalizeBaseUrl(overrides.baseUrl || DEFAULT_BASE_URL) || DEFAULT_BASE_URL,
cookie: String(overrides.cookie || '').trim(),
timeoutMs: normalizePositiveInteger(overrides.timeoutMs, 8000),
infoPath: normalizePath(overrides.infoPath, '/gateway/pc/workbench/service/stable/info'),
detailPath: normalizePath(overrides.detailPath, '/gateway/industry/eticket/consume/detail'),
consumePath: normalizePath(overrides.consumePath, '/gateway/industry/eticket/consume'),
refererPath: normalizePath(overrides.refererPath, DEFAULT_REFERER_PATH),
userAgent: String(overrides.userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT,
}
}
export function buildKuaishouEticketUrl(baseUrl: unknown, pathname: unknown): URL {
return new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL)
}
export function buildKuaishouEticketHeaders({
baseUrl = DEFAULT_BASE_URL,
refererPath = DEFAULT_REFERER_PATH,
userAgent = DEFAULT_USER_AGENT,
cookie = '',
contentType = 'application/json',
extra = {},
}: HeaderInput = {}): Record<string, string> {
return {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
...(contentType ? { 'content-type': contentType } : {}),
kpf: 'PC_WEB',
kpn: 'KWAIXIAODIAN',
origin: normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
referer: buildKuaishouEticketUrl(baseUrl, refererPath).toString(),
'user-agent': String(userAgent || DEFAULT_USER_AGENT).trim() || DEFAULT_USER_AGENT,
...(String(cookie || '').trim() ? { cookie: String(cookie || '').trim() } : {}),
...normalizeHeaderMap(extra),
}
}
function normalizeBaseUrl(value: unknown): string {
return String(value || '').trim().replace(/\/+$/, '')
}
function normalizePath(value: unknown, fallback: string): string {
const normalized = String(value || '').trim()
if (!normalized) {
return fallback
}
return normalized.startsWith('/') ? normalized : `/${normalized}`
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function normalizeHeaderMap(extra: unknown): Record<string, string> {
if (!extra || typeof extra !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(extra)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.filter(([key, value]) => key && value),
)
}
@@ -1,200 +0,0 @@
import http from 'node:http'
import https from 'node:https'
import type { IncomingHttpHeaders } from 'node:http'
import { parseJsonObject } from '../../../utils/json.js'
import { createHttpError } from '../../../utils/http.js'
import { logInfo } from '../../../utils/logger.js'
import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './helpers.js'
type JsonObject = Record<string, any>
type KuaishouEticketRequestOptions = JsonObject & {
method?: string
body?: unknown
headers?: Record<string, unknown>
timeoutMs?: number | string
cookie?: string
contentType?: string | null
}
type HeaderAdapter = {
get(name: string): string | null
}
type NodeHttpResponse = {
ok: boolean
status: number
headers: HeaderAdapter
bodyText: string
}
export async function kuaishouEticketRequest(pathname: string, options: KuaishouEticketRequestOptions = {}) {
const config = resolveKuaishouEticketConfig(options)
const url = buildKuaishouEticketUrl(config.baseUrl, pathname)
const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 8000)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const method = String(options.method || 'POST').trim().toUpperCase()
const headers = buildKuaishouEticketHeaders({
baseUrl: config.baseUrl,
refererPath: config.refererPath,
userAgent: config.userAgent,
cookie: options.cookie ?? config.cookie,
contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body),
...(options.headers !== undefined ? { extra: options.headers } : {}),
})
try {
const body = normalizeRequestBody(options.body, headers['content-type'])
const response = await requestViaNodeHttp(url, {
method,
headers,
signal: controller.signal,
...(body !== undefined ? { body } : {}),
})
const rawText = response.bodyText
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
if (!response.ok) {
throw createHttpError(`快手小店核销请求失败,HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'kuaishou_eticket_http_error',
})
}
logInfo('[kuaishou-eticket/http]', '请求完成', {
method,
pathname,
status: response.status,
result: Number(payload?.result || 0),
})
return {
url: url.toString(),
status: response.status,
headers: response.headers,
payload,
rawText,
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw createHttpError(`快手小店核销请求超时(${timeoutMs}ms`, {
statusCode: 504,
errorCode: 'kuaishou_eticket_request_timeout',
})
}
throw error
} finally {
clearTimeout(timer)
}
}
function inferContentType(body: unknown): string {
if (body == null) {
return ''
}
if (typeof body === 'string') {
return 'application/json'
}
return 'application/json'
}
function normalizeRequestBody(body: unknown, contentType: unknown): string | undefined {
if (body == null) {
return undefined
}
if (typeof body === 'string') {
return body
}
if (String(contentType || '').includes('application/json')) {
return JSON.stringify(body)
}
return String(body)
}
async function requestViaNodeHttp(
url: URL,
{ method, headers, body, signal }: {
method: string
headers: Record<string, string>
body?: string
signal: AbortSignal
},
): Promise<NodeHttpResponse> {
const isHttps = url.protocol === 'https:'
const transport = isHttps ? https : http
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method,
headers,
rejectUnauthorized: false,
}, (response) => {
const chunks: Buffer[] = []
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
response.on('end', () => {
const bodyText = Buffer.concat(chunks).toString('utf8')
resolve({
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
status: Number(response.statusCode || 0),
headers: createHeaderAdapter(response.headers),
bodyText,
})
})
})
request.on('error', reject)
if (signal) {
if (signal.aborted) {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
} else {
signal.addEventListener('abort', () => {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
}, { once: true })
}
}
if (typeof body !== 'undefined') {
request.write(body)
}
request.end()
})
}
function createHeaderAdapter(headers: IncomingHttpHeaders): HeaderAdapter {
const normalized = new Map<string, string>()
for (const [key, value] of Object.entries(headers || {})) {
if (Array.isArray(value)) {
normalized.set(String(key || '').toLowerCase(), value.join(', '))
continue
}
if (typeof value === 'string') {
normalized.set(String(key || '').toLowerCase(), value)
}
}
return {
get(name: string) {
return normalized.get(String(name || '').toLowerCase()) || null
},
}
}
@@ -1,32 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mapKuaishouEticketStableInfoResult } from './info-service.js'
test('mapKuaishouEticketStableInfoResult extracts shop info from payload', () => {
const result = mapKuaishouEticketStableInfoResult({
result: 1,
error_msg: '成功',
requestId: '777795601162094230',
data: {
userInfo: {
userId: 4269276762,
userName: '稚嫩游戏交易店',
userAvatar: 'https://example.com/avatar.jpg',
},
bizStatus: {
settleStatus: 200,
},
},
}, {
baseUrl: 'https://s.kwaixiaodian.com',
cookie: 'sid=abc',
})
assert.equal(result.ok, true)
assert.equal(result.shop.shopId, '4269276762')
assert.equal(result.shop.kshopName, '稚嫩游戏交易店')
assert.equal(result.shop.userAvatar, 'https://example.com/avatar.jpg')
assert.equal(result.shop.settleStatus, 200)
assert.equal(result.shop.hasCookie, true)
})
@@ -1,61 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { kuaishouEticketRequest } from './http-client.js'
import { resolveKuaishouEticketConfig } from './helpers.js'
type JsonObject = Record<string, any>
export async function queryKuaishouEticketStableInfo(payload: JsonObject = {}) {
const config = resolveKuaishouEticketConfig(payload)
const cookie = String(payload.cookie || config.cookie || '').trim()
if (!cookie) {
throw createHttpError('请先提供店铺 Cookie', {
statusCode: 400,
errorCode: 'kuaishou_eticket_missing_cookie',
})
}
const response = await kuaishouEticketRequest(config.infoPath, {
method: 'GET',
baseUrl: config.baseUrl,
cookie,
timeoutMs: config.timeoutMs,
contentType: null,
})
return mapKuaishouEticketStableInfoResult(response.payload, {
baseUrl: config.baseUrl,
cookie,
})
}
export function mapKuaishouEticketStableInfoResult(payload: JsonObject = {}, context: JsonObject = {}) {
const result = Number(payload?.result || 0)
const userInfo = isPlainObject(payload?.data?.userInfo) ? payload.data.userInfo : {}
const bizStatus = isPlainObject(payload?.data?.bizStatus) ? payload.data.bizStatus : {}
return {
baseUrl: String(context.baseUrl || '').trim(),
ok: result === 1,
result,
errorMessage: String(payload?.error_msg || '').trim(),
requestId: String(payload?.requestId || '').trim(),
shop: {
shopId: String(userInfo.userId || '').trim(),
kshopName: String(userInfo.userName || '').trim(),
userAvatar: String(userInfo.userAvatar || '').trim(),
settleStatus: normalizeInteger(bizStatus.settleStatus, 0),
hasCookie: Boolean(String(context.cookie || '').trim()),
},
raw: payload && typeof payload === 'object' ? payload : {},
}
}
function normalizeInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : fallback
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,123 +0,0 @@
import process from 'node:process'
import { isProductionLike } from '../../../config/runtime-validation.js'
type RuntimeEnvironment = {
NODE_ENV?: string
}
type MockTicketDetailOptions = {
eTicketId: string
baseUrl?: string
oid?: string
formToken?: string
leftCount?: number
goodsTitle?: string
}
type MockTicketConsumeOptions = {
eTicketId: string
baseUrl?: string
oid?: string
formToken?: string
num?: number
storeId?: string
}
export function isKuaishouEticketMockTicketCode(
ticketCode: unknown,
env: RuntimeEnvironment = process.env,
): boolean {
const normalized = String(ticketCode || '').trim().toUpperCase()
if (!normalized || isProductionLike(env)) {
return false
}
return normalized === 'MOCK' || normalized.startsWith('MOCK-') || normalized.startsWith('MOCK_')
}
export function buildMockKuaishouEticketDetailResult(options: MockTicketDetailOptions) {
const eTicketId = String(options.eTicketId || '').trim()
const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim()
const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim()
const leftCount = normalizePositiveInteger(options.leftCount, 1)
const goodsTitle = String(options.goodsTitle || 'MOCK 快手商品').trim()
return {
baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(),
eTicketId,
ok: true,
alreadyConsumed: false,
result: 1,
errorMessage: '',
requestId: `mock-${Date.now()}`,
serverTimestamp: new Date().toISOString(),
detail: {
uid: 'mock',
fulfillDetailId: `MOCK-FULFILL-${eTicketId}`,
sellerId: 'mock',
formToken,
validEndTime: '',
validStartTime: '',
leftReverseCount: 0,
eTicketId,
oid,
totalCount: leftCount,
leftCount,
status: 'MOCK',
},
goods: {
itemId: 'mock',
itemPicUrl: '',
itemTitle: goodsTitle,
price: '',
skuDesc: goodsTitle,
skuId: 'mock',
},
raw: {
mock: true,
},
}
}
export function buildMockKuaishouEticketConsumeResult(options: MockTicketConsumeOptions) {
const eTicketId = String(options.eTicketId || '').trim()
const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim()
const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim()
const num = normalizePositiveInteger(options.num, 1)
return {
baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(),
eTicketId,
oid,
formToken,
num,
storeId: String(options.storeId || '0').trim() || '0',
ok: true,
consumed: true,
alreadyConsumed: false,
result: 1,
errorMessage: '',
requestId: `mock-${Date.now()}`,
serverTimestamp: new Date().toISOString(),
detail: {
eTicketId,
oid,
formToken,
leftCount: num,
},
raw: {
mock: true,
},
}
}
function normalizePositiveInteger(value: unknown, fallback: number) {
const normalized = Number(value)
if (!Number.isFinite(normalized) || normalized <= 0) {
return fallback
}
return Math.floor(normalized)
}
@@ -1,158 +0,0 @@
import path from 'node:path'
import { PROJECT_ROOT } from '../../../config/runtime.js'
import { readJsonFile, writeJsonFile } from '../../../utils/json-file-store.js'
const KUAISHOU_ETICKET_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-eticket-source.json')
const DEFAULT_BASE_URL = 'https://s.kwaixiaodian.com'
type JsonObject = Record<string, any>
export type KuaishouEticketShopConfig = {
shopId: string
kshopName: string
cookie: string
userAvatar: string
enabled: boolean
}
export type KuaishouEticketSourceConfig = {
enabled: boolean
baseUrl: string
shops: KuaishouEticketShopConfig[]
}
export function getKuaishouEticketSourceFilePath(): string {
return KUAISHOU_ETICKET_SOURCE_FILE_PATH
}
export function getKuaishouEticketSourceConfig(): KuaishouEticketSourceConfig {
return loadKuaishouEticketSourceConfigFromFile()
}
export function saveKuaishouEticketSourceConfig(rawValue: unknown): KuaishouEticketSourceConfig {
return writeJsonFile(
KUAISHOU_ETICKET_SOURCE_FILE_PATH,
rawValue,
normalizeKuaishouEticketSourceConfig,
)
}
export function listKuaishouEticketShopConfigs(source: KuaishouEticketSourceConfig = getKuaishouEticketSourceConfig()): KuaishouEticketShopConfig[] {
return Array.isArray(source?.shops) ? source.shops : []
}
export function findKuaishouEticketShopConfig(shopId: unknown, source: KuaishouEticketSourceConfig = getKuaishouEticketSourceConfig()): KuaishouEticketShopConfig | null {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return listKuaishouEticketShopConfigs(source).find((item) => String(item.shopId || '').trim() === normalizedShopId) || null
}
export function findKuaishouEticketShopConfigByName(kshopName: unknown, source: KuaishouEticketSourceConfig = getKuaishouEticketSourceConfig()): KuaishouEticketShopConfig | null {
const normalizedName = String(kshopName || '').trim()
if (!normalizedName) {
return null
}
return listKuaishouEticketShopConfigs(source).find((item) => String(item.kshopName || '').trim() === normalizedName) || null
}
export function resolveKuaishouEticketShopConfig(
{ shopId = '', kshopName = '', shopName = '' }: { shopId?: unknown, kshopName?: unknown, shopName?: unknown } = {},
source: KuaishouEticketSourceConfig = getKuaishouEticketSourceConfig(),
): KuaishouEticketShopConfig | null {
const byId = findKuaishouEticketShopConfig(shopId, source)
if (byId) {
return byId
}
const normalizedName = String(kshopName || shopName || '').trim()
if (!normalizedName) {
return null
}
return findKuaishouEticketShopConfigByName(normalizedName, source)
}
export function getFirstAvailableKuaishouEticketShop(source: KuaishouEticketSourceConfig = getKuaishouEticketSourceConfig()): KuaishouEticketShopConfig | null {
return listKuaishouEticketShopConfigs(source).find((item) => item.enabled !== false && String(item.cookie || '').trim()) || null
}
function loadKuaishouEticketSourceConfigFromFile(): KuaishouEticketSourceConfig {
return readJsonFile(
KUAISHOU_ETICKET_SOURCE_FILE_PATH,
createDefaultKuaishouEticketSourceConfig,
normalizeKuaishouEticketSourceConfig,
)
}
function normalizeKuaishouEticketSourceConfig(rawValue: unknown): KuaishouEticketSourceConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
const shops = Array.isArray(source.shops)
? source.shops
: buildLegacySingleShopList(source)
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
baseUrl: String(source.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL,
shops: shops
.map((item) => normalizeKuaishouEticketShopConfig(item))
.filter((item): item is KuaishouEticketShopConfig => Boolean(item)),
}
}
function normalizeKuaishouEticketShopConfig(rawValue: unknown): KuaishouEticketShopConfig | null {
if (!isPlainObject(rawValue)) {
return null
}
const shopId = String(rawValue.shopId || rawValue.userId || '').trim()
const kshopName = String(rawValue.kshopName || rawValue.shopName || rawValue.userName || '').trim()
const cookie = String(rawValue.cookie || '').trim()
const userAvatar = String(rawValue.userAvatar || '').trim()
const enabled = typeof rawValue.enabled === 'boolean' ? rawValue.enabled : true
if (!shopId && !kshopName && !cookie) {
return null
}
return {
shopId,
kshopName,
cookie,
userAvatar,
enabled,
}
}
function buildLegacySingleShopList(source: JsonObject): JsonObject[] {
const cookie = String(source.cookie || '').trim()
if (!cookie) {
return []
}
return [
{
shopId: '',
kshopName: '',
cookie,
userAvatar: '',
enabled: source.enabled !== false,
},
]
}
function createDefaultKuaishouEticketSourceConfig(): KuaishouEticketSourceConfig {
return {
enabled: true,
baseUrl: DEFAULT_BASE_URL,
shops: [],
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -24,15 +24,10 @@ import type {
AdminCloudtentaclesTestLoginInput,
AdminCloudtentaclesValidateSessionInput,
AdminCloudtentaclesVirtualNumberInput,
AdminKuaishouEticketConsumeInput,
AdminKuaishouEticketDetailQueryInput,
AdminKuaishouEticketShopInfoInput,
AdminKuaishouEticketSourceConfigInput,
AdminNotificationConfigInput,
AdminNotificationTestInput,
AdminScheduledJobsConfigInput,
AdminTaskKuaishouIndustryConsumeInput,
AdminTaskKuaishouCloudDispatchInput,
AdminTaskManualDispatchInput,
} from './write-inputs.js'
@@ -40,7 +35,6 @@ export type AdminOrderRouteQuery = AdminOrderListQueryInput
export type AdminTaskRouteQuery = AdminTaskListQueryInput
export type AdminRouteAdminSession = AdminViewerSessionInput
export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput
export type AdminKuaishouIndustrySourceConfigRouteBody = AdminKuaishouIndustrySourceConfigInput
export type AdminKuaishouIndustryAuthorizationCodeRouteBody = AdminKuaishouIndustryAuthorizationCodeInput
export type AdminKuaishouIndustryRefundListRouteBody = AdminKuaishouIndustryRefundListInput
@@ -53,9 +47,6 @@ export type AdminKuaishouIndustryVoucherResendRouteBody = AdminKuaishouIndustryV
export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput
export type AdminNotificationTestRouteBody = AdminNotificationTestInput
export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput
export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetailQueryInput
export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput
export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput
export type AdminCloudtentaclesSourceConfigRouteBody = AdminCloudtentaclesSourceConfigInput
export type AdminCloudtentaclesOverrideRuleConfigRouteBody = AdminCloudtentaclesOverrideRuleConfigInput
export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSmsCodeInput
@@ -68,7 +59,6 @@ export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput
export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput
export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput
export type AdminTaskKuaishouIndustryConsumeRouteBody = AdminTaskKuaishouIndustryConsumeInput
export type AdminOrderRouteParams = { orderId?: string }
@@ -1,17 +1,3 @@
export type AdminKuaishouEticketShopConfigWriteItemInput = {
shopId?: string
kshopName?: string
cookie?: string
userAvatar?: string
enabled?: boolean
}
export type AdminKuaishouEticketSourceConfigInput = {
enabled?: boolean
baseUrl?: string
shops?: AdminKuaishouEticketShopConfigWriteItemInput[]
}
export type AdminKuaishouIndustryShopConfigInput = {
enabled?: boolean
sellerId?: string
@@ -122,30 +108,6 @@ export type AdminScheduledJobsConfigInput = {
jobs?: AdminScheduledJobInput[]
}
export type AdminKuaishouEticketShopInfoInput = {
baseUrl?: string
shopId?: string
cookie?: string
}
export type AdminKuaishouEticketDetailQueryInput = {
baseUrl?: string
shopId?: string
cookie?: string
eTicketId?: string
}
export type AdminKuaishouEticketConsumeInput = {
baseUrl?: string
shopId?: string
cookie?: string
eTicketId?: string
oid?: string
formToken?: string
num?: number | string
storeId?: string
}
export type AdminCloudtentaclesSourceConfigInput = {
sourceKey?: string
enabled?: boolean
@@ -274,10 +236,6 @@ export type AdminTaskManualDispatchInput = {
deliveredCredential?: string
}
export type AdminTaskKuaishouCloudDispatchInput = {
ticketCode?: string
}
export type AdminTaskKuaishouIndustryConsumeInput = {
consumeType?: string
storeName?: string