统一前后端代码格式化配置

This commit is contained in:
yml2213
2026-08-16 17:27:12 +08:00
parent 8705f6a6a1
commit 5c7c3e14e3
278 changed files with 6386 additions and 5500 deletions
@@ -1,9 +1,17 @@
import { createAdminAuditLog, listAdminAuditLogs } from '../../repositories/admin-audit-log-repo.js'
import { nowIso } from '../../utils/time.js'
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
import {
normalizeDateQuery,
normalizePage,
normalizePageSize,
safeParseJson,
} from './admin-query-utils.js'
import type { JsonObject } from '../../types/json.js'
export async function writeAdminAuditLog(session: JsonObject | null | undefined, payload: JsonObject = {}) {
export async function writeAdminAuditLog(
session: JsonObject | null | undefined,
payload: JsonObject = {},
) {
if (!session?.userId) {
return null
}
@@ -32,10 +32,14 @@ type AdminUserStatus = 'active' | 'disabled'
export async function ensureAdminUsersBootstrapped(): Promise<void> {
ensureAdminAuthConfigured()
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : []
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers)
? runtimeConfig.admin.defaultUsers
: []
for (const configuredUser of configuredUsers) {
const username = String(configuredUser?.username || '').trim().toLowerCase()
const username = String(configuredUser?.username || '')
.trim()
.toLowerCase()
const password = String(configuredUser?.password || '').trim()
const role = normalizeAdminRole(configuredUser?.role)
@@ -70,7 +74,9 @@ export async function loginAdmin(
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = String(username || '').trim().toLowerCase()
const normalizedUsername = String(username || '')
.trim()
.toLowerCase()
const normalizedPassword = String(password || '').trim()
if (!normalizedUsername || !normalizedPassword) {
@@ -87,7 +93,11 @@ export async function loginAdmin(
}
const user = await getAdminUserByUsername(normalizedUsername)
if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) {
if (
!user ||
user.status !== 'active' ||
!verifyAdminPassword(normalizedPassword, user.password_hash)
) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username: normalizedUsername,
@@ -201,7 +211,10 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
}
}
export function requireAdminRole(session: { role?: string } | null | undefined, allowedRoles: string[]): void {
export function requireAdminRole(
session: { role?: string } | null | undefined,
allowedRoles: string[],
): void {
if (session && allowedRoles.includes(session.role || '')) {
return
}
@@ -342,7 +355,10 @@ export async function updateManagedAdminUserStatus(
}
}
export async function resetManagedAdminUserPassword(userId: number | string, payload: JsonObject = {}): Promise<JsonObject> {
export async function resetManagedAdminUserPassword(
userId: number | string,
payload: JsonObject = {},
): Promise<JsonObject> {
const user = await getRequiredAdminUser(userId)
const password = normalizePassword(payload.password)
@@ -373,7 +389,9 @@ export async function resetManagedAdminUserPassword(userId: number | string, pay
export function ensureAdminAuthConfigured(): void {
const sessionSecret = String(runtimeConfig.admin?.sessionSecret || '').trim()
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : []
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers)
? runtimeConfig.admin.defaultUsers
: []
if (sessionSecret && configuredUsers.length > 0) {
return
@@ -435,7 +453,9 @@ function signPayload(encodedPayload: string): string {
}
export function normalizeAdminRole(role: unknown): AdminRole {
const normalized = String(role || '').trim().toLowerCase()
const normalized = String(role || '')
.trim()
.toLowerCase()
if (normalized === 'admin') {
return 'admin'
@@ -449,7 +469,11 @@ export function normalizeAdminRole(role: unknown): AdminRole {
}
export function normalizeAdminUserStatus(status: unknown): AdminUserStatus {
return String(status || '').trim().toLowerCase() === 'disabled' ? 'disabled' : 'active'
return String(status || '')
.trim()
.toLowerCase() === 'disabled'
? 'disabled'
: 'active'
}
function safeCompare(input: unknown, expected: unknown): boolean {
@@ -464,17 +488,23 @@ function safeCompare(input: unknown, expected: unknown): boolean {
}
function normalizeRoleQuery(role: unknown): string {
const normalized = String(role || '').trim().toLowerCase()
const normalized = String(role || '')
.trim()
.toLowerCase()
return ['admin', 'operator', 'support'].includes(normalized) ? normalized : ''
}
function normalizeStatusQuery(status: unknown): string {
const normalized = String(status || '').trim().toLowerCase()
const normalized = String(status || '')
.trim()
.toLowerCase()
return ['active', 'disabled'].includes(normalized) ? normalized : ''
}
function normalizeUsername(username: unknown): string {
return String(username || '').trim().toLowerCase()
return String(username || '')
.trim()
.toLowerCase()
}
function normalizePassword(password: unknown): string {
@@ -523,7 +553,7 @@ async function getRequiredAdminUser(userId: number | string): Promise<AdminUserR
async function ensureAdminUserChangeAllowed(
user: AdminUserRow,
options: { nextRole?: AdminRole, nextStatus?: AdminUserStatus } = {},
options: { nextRole?: AdminRole; nextStatus?: AdminUserStatus } = {},
session: AdminSession,
): Promise<void> {
const nextRole = options.nextRole || user.role
@@ -536,7 +566,11 @@ async function ensureAdminUserChangeAllowed(
})
}
if (user.role === 'admin' && (nextRole !== 'admin' || nextStatus !== 'active') && await countActiveAdminUsers() <= 1) {
if (
user.role === 'admin' &&
(nextRole !== 'admin' || nextStatus !== 'active') &&
(await countActiveAdminUsers()) <= 1
) {
throw createHttpError('至少保留一个启用中的管理员账号', {
statusCode: 409,
errorCode: 'admin_user_last_admin_not_allowed',
@@ -555,11 +589,13 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
}
}
function pickLoginMeta(meta: {
ip?: string
userAgent?: string
location?: string
} = {}) {
function pickLoginMeta(
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
) {
const result: {
ip?: string
userAgent?: string
@@ -11,15 +11,8 @@ export async function getAdminDashboardSummary() {
TASK_STATUS.PENDING_BINDING_PREPARE,
TASK_STATUS.WAITING_BINDING,
]
const claimingStatuses = [
TASK_STATUS.CLAIMED,
TASK_STATUS.ROLE_CONFIRMED,
TASK_STATUS.REDEEMING,
]
const abnormalStatuses = [
TASK_STATUS.RETRY_PENDING,
TASK_STATUS.MANUAL_REVIEW,
]
const claimingStatuses = [TASK_STATUS.CLAIMED, TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING]
const abnormalStatuses = [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW]
const result = await query(
`
SELECT
@@ -1,17 +1,10 @@
import type { Request } from 'express'
import type { JsonObject } from '../../types/json.js'
import {
createAdminLoginLog,
listAdminLoginLogs,
} from '../../repositories/admin-login-log-repo.js'
import { createAdminLoginLog, listAdminLoginLogs } from '../../repositories/admin-login-log-repo.js'
import { logWarn } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import {
normalizeDateQuery,
normalizePage,
normalizePageSize,
} from './admin-query-utils.js'
import { normalizeDateQuery, normalizePage, normalizePageSize } from './admin-query-utils.js'
type RecordAdminLoginInput = {
userId?: number | null
@@ -108,16 +101,8 @@ export function resolveClientLocation(req: Request) {
'cloudfront-viewer-country',
'x-country-code',
])
const city = firstHeader(req, [
'cf-ipcity',
'x-vercel-ip-city',
'x-city',
])
const region = firstHeader(req, [
'cf-region',
'x-vercel-ip-country-region',
'x-region',
])
const city = firstHeader(req, ['cf-ipcity', 'x-vercel-ip-city', 'x-city'])
const region = firstHeader(req, ['cf-region', 'x-vercel-ip-country-region', 'x-region'])
return [country, region, city].filter(Boolean).join(' · ')
}
@@ -34,7 +34,10 @@ export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOr
createdAt: item.created_at,
updatedAt: item.updated_at,
itemCount: orderItems.length,
totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0),
totalQuantity: orderItems.reduce(
(sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)),
0,
),
itemSummary,
taskCount: Number(item.task_count || tasks.length || 0),
resourceStatus: fulfillmentProgress.resourceStatus,
@@ -52,8 +55,9 @@ export function summarizeOrderItems(items: OrderItemRow[] | null | undefined): s
}
const [firstItem] = normalizedItems
const firstLabel = resolveOrderItemTitle(firstItem)
|| String(firstItem?.sku_name || firstItem?.sku_code || '').trim()
const firstLabel =
resolveOrderItemTitle(firstItem) ||
String(firstItem?.sku_name || firstItem?.sku_code || '').trim()
if (normalizedItems.length === 1) {
return firstLabel
@@ -66,10 +66,7 @@ import type {
AdminTaskListQueryInput,
AdminViewerSessionInput,
} from '../../types/admin/read-inputs.js'
import type {
KuaishouIndustryVoucherRow,
TaskRow,
} from '../../types/repository/rows.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../types/repository/rows.js'
export async function getAdminOrders(
query: AdminOrderListQueryInput = {},
@@ -229,15 +226,16 @@ export async function getAdminTaskDetail(
}
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
const [order, claimToken, taskEvents, taskKuaishouIndustryVouchers, oidKuaishouIndustryVouchers] = await Promise.all([
getOrderById(task.order_id),
primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null),
listTaskEventsByTaskId(task.id),
listKuaishouIndustryVouchersByTaskId(task.id),
task.platform_order_id
? listKuaishouIndustryVouchersByOid(task.platform_order_id)
: Promise.resolve([]),
])
const [order, claimToken, taskEvents, taskKuaishouIndustryVouchers, oidKuaishouIndustryVouchers] =
await Promise.all([
getOrderById(task.order_id),
primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null),
listTaskEventsByTaskId(task.id),
listKuaishouIndustryVouchersByTaskId(task.id),
task.platform_order_id
? listKuaishouIndustryVouchersByOid(task.platform_order_id)
: Promise.resolve([]),
])
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
const taskContext = parseTaskContext(task)
@@ -252,10 +250,7 @@ export async function getAdminTaskDetail(
cloudSourceLabelMap,
}) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
const claimIdentity = buildClaimIdentityAdminSummary(taskContext, {
flowLike:
taskContext.kuaishouCloudFulfillment ||
taskContext.kuaishouFeifei ||
null,
flowLike: taskContext.kuaishouCloudFulfillment || taskContext.kuaishouFeifei || null,
taskRoleId: task.role_id,
taskRoleName: task.role_name,
})
@@ -265,17 +260,20 @@ export async function getAdminTaskDetail(
taskKuaishouIndustryVouchers,
oidKuaishouIndustryVouchers,
)
const kuaishouIndustryVoucher =
firstKuaishouIndustryVoucher
? mapAdminKuaishouIndustryVoucher(firstKuaishouIndustryVoucher)
: mapKuaishouIndustryVoucherContext(taskContext.kuaishouIndustryVoucher)
const kuaishouIndustryVoucher = firstKuaishouIndustryVoucher
? mapAdminKuaishouIndustryVoucher(firstKuaishouIndustryVoucher)
: mapKuaishouIndustryVoucherContext(taskContext.kuaishouIndustryVoucher)
const hasKuaishouIndustryVoucher = Boolean(
kuaishouIndustryVoucher && String(kuaishouIndustryVoucher.voucherCode || '').trim(),
)
const kuaishouIndustryVoucherStatus =
String(kuaishouIndustryVoucher?.status || '').trim().toUpperCase()
const kuaishouIndustryVoucherSendCallbackStatus =
String(kuaishouIndustryVoucher?.sendCallbackStatus || '').trim().toLowerCase()
const kuaishouIndustryVoucherStatus = String(kuaishouIndustryVoucher?.status || '')
.trim()
.toUpperCase()
const kuaishouIndustryVoucherSendCallbackStatus = String(
kuaishouIndustryVoucher?.sendCallbackStatus || '',
)
.trim()
.toLowerCase()
return {
task: mapAdminTaskListItem(
@@ -435,7 +433,7 @@ function resolveKuaishouIndustryVoucherForTask(
taskContext.kuaishouIndustryVoucher &&
typeof taskContext.kuaishouIndustryVoucher === 'object' &&
!Array.isArray(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher as JsonRecord
? (taskContext.kuaishouIndustryVoucher as JsonRecord)
: {}
const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim()
if (voucherCode) {
@@ -457,7 +455,7 @@ function resolveKuaishouIndustryVoucherForTask(
}
}
return oidVouchers.length === 1 ? (oidVouchers[0] || null) : null
return oidVouchers.length === 1 ? oidVouchers[0] || null : null
}
function buildCloudSourceLabelMap() {
@@ -15,7 +15,10 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot'
runtime_session_id: '',
}
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
const screenshotUrl = await resolveAdminTaskScreenshotUrl(
task,
createAdminViewerContext({ role: 'support' }),
)
assert.equal(screenshotUrl, '/api/v1/admin/tasks/12/screenshot')
})
@@ -27,7 +30,10 @@ test('resolveAdminTaskScreenshotUrl falls back to review screenshot when runtime
runtime_session_id: 'runtime-session-13',
}
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
const screenshotUrl = await resolveAdminTaskScreenshotUrl(
task,
createAdminViewerContext({ role: 'support' }),
)
assert.equal(screenshotUrl, '/api/v1/admin/tasks/13/screenshot')
})
@@ -1,6 +1,6 @@
import { safeParseJson } from './admin-query-utils.js'
import { normalizeAdminRole } from './admin-auth-service.js'
import { asJsonObject, type JsonObject, JsonRecord } from '../../types/json.js'
import { asJsonObject, type JsonObject, JsonRecord } from '../../types/json.js'
import {
parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue,
@@ -79,8 +79,7 @@ export function mapKuaishouCloudFulfillmentContext(
const role = asJsonObject(record.role)
const purchase = asJsonObject(record.purchase)
const dispatch = asJsonObject(record.dispatch)
const returnNumber =
asJsonObject(record.returnNumber)
const returnNumber = asJsonObject(record.returnNumber)
const consume = asJsonObject(record.consume)
const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys)
? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean)
@@ -373,7 +372,8 @@ export function createAdminViewerContext(
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
canManageTaskLifecycle: role === 'admin' || role === 'operator',
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
canOperateKuaishouIndustryVoucher: role === 'admin' || role === 'operator' || role === 'support',
canOperateKuaishouIndustryVoucher:
role === 'admin' || role === 'operator' || role === 'support',
}
}
@@ -1,7 +1,7 @@
import { getTaskById } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { safeParseJson } from './admin-query-utils.js'
import { asJsonObject, type JsonObject, JsonRecord } from '../../types/json.js'
import { asJsonObject, type JsonObject, JsonRecord } from '../../types/json.js'
import {
TASK_STATUS,
isTaskFulfillmentCompletedStatus,
@@ -17,10 +17,7 @@ import type {
AdminTaskListItem,
} from '../../types/admin/read-models.js'
import type { AdminTaskActionPayload } from '../../types/admin/write-models.js'
import type {
TaskEventRow,
TaskRow,
} from '../../types/repository/rows.js'
import type { TaskEventRow, TaskRow } from '../../types/repository/rows.js'
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
type TaskFulfillmentState = {
@@ -28,9 +25,7 @@ type TaskFulfillmentState = {
customerStatus: string
}
export function mapAdminTaskSummary(
task: TaskRow,
): JsonRecord {
export function mapAdminTaskSummary(task: TaskRow): JsonRecord {
const fulfillment = buildTaskFulfillmentState(task)
return {
@@ -111,8 +106,10 @@ export function mapAdminTaskListItem(
lastError: task.last_error,
createdAt: task.created_at,
updatedAt: task.updated_at,
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
claimToken: viewerContext.canViewSensitiveTaskData
? task.primary_claim_token || task.claim_token || ''
: '',
screenshotPath: viewerContext.role === 'support' ? '' : task.screenshot_path || '',
}
}
@@ -134,7 +131,9 @@ export function buildOrderFulfillmentProgress(
const totalTaskCount = normalizedTasks.length
const taskFulfillments = normalizedTasks.map((task) => buildTaskFulfillmentState(task))
const preparedTaskCount = normalizedTasks.filter((task) => isTaskResourcePrepared(task)).length
const completedTaskCount = normalizedTasks.filter((task) => isTaskFulfillmentCompleted(task)).length
const completedTaskCount = normalizedTasks.filter((task) =>
isTaskFulfillmentCompleted(task),
).length
let resourceStatus = 'pending_prepare'
let customerStatus = 'not_started'
@@ -152,7 +151,11 @@ export function buildOrderFulfillmentProgress(
if (normalizedTasks.every((task) => isTaskFulfillmentCompleted(task))) {
resourceStatus = 'resource_ready'
customerStatus = 'customer_completed'
} else if (taskFulfillments.some((item) => ['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus))) {
} else if (
taskFulfillments.some((item) =>
['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus),
)
) {
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
customerStatus = 'customer_processing'
} else if (taskFulfillments.some((item) => item.customerStatus === 'waiting_customer')) {
@@ -163,7 +166,11 @@ export function buildOrderFulfillmentProgress(
customerStatus = 'customer_exception'
} else if (preparedTaskCount > 0) {
resourceStatus = 'resource_ready'
} else if (taskFulfillments.some((item) => ['manual_review', 'retry_pending'].includes(item.resourceStatus))) {
} else if (
taskFulfillments.some((item) =>
['manual_review', 'retry_pending'].includes(item.resourceStatus),
)
) {
resourceStatus = 'resource_exception'
}
@@ -1,8 +1,6 @@
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.js'
import type { JsonObject } from '../../../types/json.js'
import {
getAffiliateDashWallet,
} from '../../platforms/affiliate-dash/order-service.js'
import { getAffiliateDashWallet } from '../../platforms/affiliate-dash/order-service.js'
import { listAffiliateDashProducts } from '../../platforms/affiliate-dash/product-service.js'
import {
getAffiliateDashSourceConfig,
@@ -35,9 +33,7 @@ export async function updateAdminAffiliateDashConfig(payload: JsonObject = {}) {
export function matchAdminAffiliateDashSku(payload: JsonObject = {}) {
const productNo = String(payload.productNo || payload.product_no || '').trim()
const source = getAdminEditableAffiliateDashConfig()
const sku = productNo
? String(source.skuMapping[productNo] || '').trim()
: ''
const sku = productNo ? String(source.skuMapping[productNo] || '').trim() : ''
return {
productNo,
@@ -52,7 +52,13 @@ test('resolveCloudtentaclesAdminContext merges payload source and persisted sess
test('hasCloudtentaclesCredentialContextChanged detects normalized credential changes', () => {
assert.equal(
hasCloudtentaclesCredentialContextChanged(
{ baseUrl: ' https://a ', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
{
baseUrl: ' https://a ',
username: 'u1',
phone: '13812345678',
deviceId: 'd1',
deviceType: 1,
},
{ baseUrl: 'https://a', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 },
),
false,
@@ -1,70 +1,57 @@
export function pickFirstNonEmpty(values: unknown[]) {
for (const value of values) {
const normalized = String(value || "").trim();
const normalized = String(value || '').trim()
if (normalized) {
return normalized;
return normalized
}
}
return "";
return ''
}
export function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js";
import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js";
import { getCloudtentaclesSourceByKey } from '../../../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionStateByKey } from '../../../platforms/cloudtentacles/session-state-service.js'
import type { JsonObject } from '../../../../types/json.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from "../../../platforms/cloudtentacles/defaults.js";
} from '../../../platforms/cloudtentacles/defaults.js'
export function resolveCloudtentaclesAdminContext(payload: JsonObject = {}, options: JsonObject = {}) {
const sourceKey = String(
payload.sourceKey || options.sourceKey || "default"
).trim();
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {};
const persistedSession = options.persistedSession ||
getCloudtentaclesSessionStateByKey(sourceKey) ||
{};
const defaultBaseUrl = String(
options.defaultBaseUrl || "https://123.207.217.176"
).trim();
export function resolveCloudtentaclesAdminContext(
payload: JsonObject = {},
options: JsonObject = {},
) {
const sourceKey = String(payload.sourceKey || options.sourceKey || 'default').trim()
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {}
const persistedSession =
options.persistedSession || getCloudtentaclesSessionStateByKey(sourceKey) || {}
const defaultBaseUrl = String(options.defaultBaseUrl || 'https://123.207.217.176').trim()
return {
sourceKey,
baseUrl: pickFirstNonEmpty([
payload.baseUrl,
savedSource.baseUrl,
defaultBaseUrl,
]),
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]),
token: pickFirstNonEmpty([payload.token, persistedSession.token]),
deviceId: normalizeCloudtentaclesDeviceId(
pickFirstNonEmpty([
payload.deviceId,
savedSource.deviceId,
persistedSession.deviceId,
])
pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, persistedSession.deviceId]),
),
deviceType: normalizeCloudtentaclesDeviceType(
payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType
payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType,
),
};
}
}
export function hasCloudtentaclesCredentialContextChanged(
current: JsonObject = {},
next: JsonObject = {}
next: JsonObject = {},
) {
return (
String(current.baseUrl || "").trim() !==
String(next.baseUrl || "").trim() ||
String(current.username || "").trim() !==
String(next.username || "").trim() ||
String(current.phone || "").trim() !== String(next.phone || "").trim() ||
String(current.deviceId || "").trim() !==
String(next.deviceId || "").trim() ||
String(current.baseUrl || '').trim() !== String(next.baseUrl || '').trim() ||
String(current.username || '').trim() !== String(next.username || '').trim() ||
String(current.phone || '').trim() !== String(next.phone || '').trim() ||
String(current.deviceId || '').trim() !== String(next.deviceId || '').trim() ||
Number(current.deviceType || 0) !== Number(next.deviceType || 0)
);
)
}
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
mapAdminCloudtentaclesSession,
maskPhone,
maskSecret,
} from './mappers.js'
import { mapAdminCloudtentaclesSession, maskPhone, maskSecret } from './mappers.js'
test('maskSecret preserves edges while hiding middle characters', () => {
assert.equal(maskSecret('abcdef1234567890'), 'abcdef****567890')
@@ -2,44 +2,44 @@ import type { JsonObject } from '../../../../types/json.js'
import {
maskPhone as maskPhoneValue,
maskSecret as maskSecretValue,
} from "../../../../utils/masking.js";
} from '../../../../utils/masking.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from "../../../platforms/cloudtentacles/defaults.js";
} from '../../../platforms/cloudtentacles/defaults.js'
export function maskSecret(value: unknown) {
return maskSecretValue(value);
return maskSecretValue(value)
}
export function maskPhone(value: unknown) {
return maskPhoneValue(value, { maskShort: false });
return maskPhoneValue(value, { maskShort: false })
}
export function mapAdminCloudtentaclesSourceConfig(config: JsonObject = {}) {
return {
key: String(config.key || "").trim(),
label: String(config.label || "").trim(),
key: String(config.key || '').trim(),
label: String(config.label || '').trim(),
enabled: config.enabled !== false,
baseUrl: String(config.baseUrl || "").trim(),
username: String(config.username || "").trim(),
password: String(config.password || "").trim(),
phone: String(config.phone || "").trim(),
baseUrl: String(config.baseUrl || '').trim(),
username: String(config.username || '').trim(),
password: String(config.password || '').trim(),
phone: String(config.phone || '').trim(),
deviceId: normalizeCloudtentaclesDeviceId(config.deviceId),
deviceType: normalizeCloudtentaclesDeviceType(config.deviceType),
};
}
}
export function mapAdminCloudtentaclesSession(session: JsonObject = {}) {
return {
token: String(session.token || "").trim(),
token: String(session.token || '').trim(),
tokenMasked: maskSecret(session.token),
baseUrl: String(session.baseUrl || "").trim(),
username: String(session.username || "").trim(),
baseUrl: String(session.baseUrl || '').trim(),
username: String(session.username || '').trim(),
phoneMasked: maskPhone(session.phone),
loggedInAt: String(session.loggedInAt || "").trim(),
loggedInAt: String(session.loggedInAt || '').trim(),
deviceId: normalizeCloudtentaclesDeviceId(session.deviceId),
deviceType: normalizeCloudtentaclesDeviceType(session.deviceType),
hasToken: Boolean(String(session.token || "").trim()),
};
hasToken: Boolean(String(session.token || '').trim()),
}
}
@@ -1,96 +1,80 @@
import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js";
import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js";
import { getCloudtentaclesSourceByKey } from '../../../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionStateByKey } from '../../../platforms/cloudtentacles/session-state-service.js'
import type { JsonObject } from '../../../../types/json.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from "../../../platforms/cloudtentacles/defaults.js";
import {
pickFirstNonEmpty,
resolveCloudtentaclesAdminContext,
} from "./context.js";
import { maskPhone, maskSecret } from "./mappers.js";
} from '../../../platforms/cloudtentacles/defaults.js'
import { pickFirstNonEmpty, resolveCloudtentaclesAdminContext } from './context.js'
import { maskPhone, maskSecret } from './mappers.js'
const DEFAULT_CLOUDTENTACLES_BASE_URL = "https://123.207.217.176";
const DEFAULT_CLOUDTENTACLES_BASE_URL = 'https://123.207.217.176'
function _resolveSourceKey(payload: JsonObject = {}) {
return String(payload.sourceKey || "").trim() || "default";
return String(payload.sourceKey || '').trim() || 'default'
}
export function resolveAdminCloudtentaclesCredentialPayload(
payload: JsonObject = {},
options: JsonObject = {}
options: JsonObject = {},
) {
const sourceKey = _resolveSourceKey(payload);
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {};
const defaultBaseUrl = String(
options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL
).trim();
const sourceKey = _resolveSourceKey(payload)
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {}
const defaultBaseUrl = String(options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL).trim()
return {
sourceKey,
baseUrl: pickFirstNonEmpty([
payload.baseUrl,
savedSource.baseUrl,
defaultBaseUrl,
]),
baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]),
username: pickFirstNonEmpty([payload.username, savedSource.username]),
password: pickFirstNonEmpty([payload.password, savedSource.password]),
phone: pickFirstNonEmpty([payload.phone, savedSource.phone]),
deviceId: normalizeCloudtentaclesDeviceId(
pickFirstNonEmpty([payload.deviceId, savedSource.deviceId])
pickFirstNonEmpty([payload.deviceId, savedSource.deviceId]),
),
deviceType: normalizeCloudtentaclesDeviceType(
payload.deviceType ?? savedSource.deviceType
),
};
deviceType: normalizeCloudtentaclesDeviceType(payload.deviceType ?? savedSource.deviceType),
}
}
export function resolveAdminCloudtentaclesSessionPayload(
payload: JsonObject = {},
options: JsonObject = {}
options: JsonObject = {},
) {
const sourceKey = _resolveSourceKey(payload);
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {};
const persistedSession = options.persistedSession ||
getCloudtentaclesSessionStateByKey(sourceKey) ||
{};
const sourceKey = _resolveSourceKey(payload)
const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {}
const persistedSession =
options.persistedSession || getCloudtentaclesSessionStateByKey(sourceKey) || {}
return resolveCloudtentaclesAdminContext(payload, {
savedSource,
persistedSession,
sourceKey,
defaultBaseUrl: options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL,
});
})
}
export function buildAdminCloudtentaclesPersistedSessionPayload(
session: JsonObject = {},
options: JsonObject = {}
options: JsonObject = {},
) {
return {
token: String(session.token || "").trim(),
baseUrl: String(session.baseUrl || "").trim(),
token: String(session.token || '').trim(),
baseUrl: String(session.baseUrl || '').trim(),
username: pickFirstNonEmpty([options.username, session.username]),
phone: pickFirstNonEmpty([options.phone, session.phone]),
loggedInAt: String(session.loggedInAt || "").trim(),
loggedInAt: String(session.loggedInAt || '').trim(),
deviceId: normalizeCloudtentaclesDeviceId(
pickFirstNonEmpty([options.deviceId, session.deviceId])
pickFirstNonEmpty([options.deviceId, session.deviceId]),
),
deviceType: normalizeCloudtentaclesDeviceType(
options.deviceType ?? session.deviceType
),
};
deviceType: normalizeCloudtentaclesDeviceType(options.deviceType ?? session.deviceType),
}
}
export function buildAdminCloudtentaclesSessionSummary(
session: JsonObject = {},
savedSession: JsonObject = {},
sourceKey = "default"
sourceKey = 'default',
) {
const permissions = Array.isArray(session.permissions)
? session.permissions
: [];
const permissions = Array.isArray(session.permissions) ? session.permissions : []
return {
sourceKey,
@@ -98,47 +82,39 @@ export function buildAdminCloudtentaclesSessionSummary(
permissionCount: permissions.length,
permissions,
persisted: Boolean(savedSession.token),
};
}
}
export function buildAdminCloudtentaclesLoginResult(
session: JsonObject = {},
savedSession: JsonObject = {},
sourceKey = "default"
sourceKey = 'default',
) {
return {
sourceKey,
baseUrl: String(session.baseUrl || "").trim(),
username: String(session.username || "").trim(),
baseUrl: String(session.baseUrl || '').trim(),
username: String(session.username || '').trim(),
phoneMasked: maskPhone(session.phone),
loggedInAt: String(session.loggedInAt || "").trim(),
responseMessage: String(session.responseMessage || "").trim(),
token: String(session.token || "").trim(),
session: buildAdminCloudtentaclesSessionSummary(
session,
savedSession,
sourceKey
),
loggedInAt: String(session.loggedInAt || '').trim(),
responseMessage: String(session.responseMessage || '').trim(),
token: String(session.token || '').trim(),
session: buildAdminCloudtentaclesSessionSummary(session, savedSession, sourceKey),
userInfo: session.userInfo,
asset: session.asset,
};
}
}
export function buildAdminCloudtentaclesValidateResult(
session: JsonObject = {},
savedSession: JsonObject = {},
sourceKey = "default"
sourceKey = 'default',
) {
return {
sourceKey,
baseUrl: String(session.baseUrl || "").trim(),
loggedInAt: String(session.loggedInAt || "").trim(),
session: buildAdminCloudtentaclesSessionSummary(
session,
savedSession,
sourceKey
),
baseUrl: String(session.baseUrl || '').trim(),
loggedInAt: String(session.loggedInAt || '').trim(),
session: buildAdminCloudtentaclesSessionSummary(session, savedSession, sourceKey),
userInfo: session.userInfo,
asset: session.asset,
};
}
}
@@ -2,22 +2,19 @@ import type { JsonObject } from '../../../../types/json.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from "../../../platforms/cloudtentacles/defaults.js";
} from '../../../platforms/cloudtentacles/defaults.js'
export function normalizeAdminCloudtentaclesSourceConfigPayload(
payload: JsonObject = {}
) {
const sourceKey =
String(payload.sourceKey || payload.key || "").trim() || "default";
export function normalizeAdminCloudtentaclesSourceConfigPayload(payload: JsonObject = {}) {
const sourceKey = String(payload.sourceKey || payload.key || '').trim() || 'default'
return {
key: sourceKey,
label: String(payload.label || "").trim(),
label: String(payload.label || '').trim(),
enabled: payload.enabled !== false,
baseUrl: String(payload.baseUrl || "").trim() || "https://123.207.217.176",
username: String(payload.username || "").trim(),
password: String(payload.password || "").trim(),
phone: String(payload.phone || "").trim(),
baseUrl: String(payload.baseUrl || '').trim() || 'https://123.207.217.176',
username: String(payload.username || '').trim(),
password: String(payload.password || '').trim(),
phone: String(payload.phone || '').trim(),
deviceId: normalizeCloudtentaclesDeviceId(payload.deviceId),
deviceType: normalizeCloudtentaclesDeviceType(payload.deviceType),
};
}
}
@@ -60,14 +60,18 @@ export async function listAdminKuaishouFeifeiProducts(payload: JsonObject = {})
page: Number(payload.page || 1) || 1,
perPage: Number(payload.perPage || payload.per_page || 20) || 20,
status: String(payload.status || 'on_sale').trim(),
supplyProductName: String(payload.supplyProductName || payload.supply_product_name || '').trim(),
supplyProductName: String(
payload.supplyProductName || payload.supply_product_name || '',
).trim(),
})
}
export async function syncAdminKuaishouFeifeiProductRules(payload: JsonObject = {}) {
const source = getAdminEditableKuaishouFeifeiConfig()
const status = String(payload.status || 'on_sale').trim() || 'on_sale'
const supplyProductName = String(payload.supplyProductName || payload.supply_product_name || '').trim()
const supplyProductName = String(
payload.supplyProductName || payload.supply_product_name || '',
).trim()
const products = await listAllKuaishouFeifeiProducts({
status,
supplyProductName,
@@ -185,10 +189,7 @@ function mapEffectiveKuaishouFeifeiConfig(config: ReturnType<typeof getKuaishouF
}
}
async function listAllKuaishouFeifeiProducts(input: {
status: string
supplyProductName: string
}) {
async function listAllKuaishouFeifeiProducts(input: { status: string; supplyProductName: string }) {
const perPage = 100
const firstPage = await listKuaishouFeifeiProducts({
page: 1,
@@ -14,16 +14,9 @@ import {
refreshKuaishouIndustryAccessToken,
} from '../../platforms/kuaishou-industry/token-service.js'
const SECRET_FIELDS = [
'appSecret',
'signSecret',
'messageSecret',
] as const
const SECRET_FIELDS = ['appSecret', 'signSecret', 'messageSecret'] as const
const SHOP_SECRET_FIELDS = [
'accessToken',
'refreshToken',
] as const
const SHOP_SECRET_FIELDS = ['accessToken', 'refreshToken'] as const
export function getAdminKuaishouIndustrySourceConfig() {
const config = getKuaishouIndustrySourceConfig()
@@ -38,15 +31,23 @@ export async function updateAdminKuaishouIndustrySourceConfig(payload: JsonObjec
const current = getKuaishouIndustrySourceConfig()
const saved = await saveKuaishouIndustrySourceConfig({
...current,
enabled: hasPayloadField(payload, 'enabled') ? payload.enabled !== false : current.enabled !== false,
enabled: hasPayloadField(payload, 'enabled')
? payload.enabled !== false
: current.enabled !== false,
baseUrl: readConfigString(payload, 'baseUrl', current.baseUrl),
authBaseUrl: readConfigString(payload, 'authBaseUrl', current.authBaseUrl),
redirectUri: readConfigString(payload, 'redirectUri', current.redirectUri, { allowBlank: true }),
scopes: normalizeScopeText(readConfigString(payload, 'scopes', current.scopes, { allowBlank: true })),
redirectUri: readConfigString(payload, 'redirectUri', current.redirectUri, {
allowBlank: true,
}),
scopes: normalizeScopeText(
readConfigString(payload, 'scopes', current.scopes, { allowBlank: true }),
),
authState: readConfigString(payload, 'authState', current.authState, { allowBlank: true }),
appKey: readConfigString(payload, 'appKey', current.appKey, { allowBlank: true }),
openId: readConfigString(payload, 'openId', current.openId, { allowBlank: true }),
grantedScopes: normalizeScopeText(readConfigString(payload, 'grantedScopes', current.grantedScopes, { allowBlank: true })),
grantedScopes: normalizeScopeText(
readConfigString(payload, 'grantedScopes', current.grantedScopes, { allowBlank: true }),
),
sellerId: readConfigString(payload, 'sellerId', current.sellerId, { allowBlank: true }),
provider: readConfigString(payload, 'provider', current.provider),
platform: readConfigString(payload, 'platform', current.platform),
@@ -245,33 +246,77 @@ function normalizeShopConfigPayload(
const payload = rawShop as JsonObject
const current = resolveCurrentShopConfig(payload, index, currentShops)
const sellerId = readConfigString(payload, 'sellerId', current?.sellerId || '', { allowBlank: true })
const shopId = readConfigString(payload, 'shopId', current?.shopId || sellerId, { allowBlank: true }) || sellerId
const shopName = readConfigString(payload, 'shopName', current?.shopName || '', { allowBlank: true })
const customShopName = readConfigString(payload, 'customShopName', current?.customShopName || '', { allowBlank: true })
const sellerId = readConfigString(payload, 'sellerId', current?.sellerId || '', {
allowBlank: true,
})
const shopId =
readConfigString(payload, 'shopId', current?.shopId || sellerId, { allowBlank: true }) ||
sellerId
const shopName = readConfigString(payload, 'shopName', current?.shopName || '', {
allowBlank: true,
})
const customShopName = readConfigString(
payload,
'customShopName',
current?.customShopName || '',
{ allowBlank: true },
)
const accessToken = readSecretString(payload, 'accessToken', current?.accessToken || '')
const refreshToken = readSecretString(payload, 'refreshToken', current?.refreshToken || '')
const openId = readConfigString(payload, 'openId', current?.openId || '', { allowBlank: true })
if (!sellerId && !shopId && !shopName && !customShopName && !accessToken && !refreshToken && !openId) {
if (
!sellerId &&
!shopId &&
!shopName &&
!customShopName &&
!accessToken &&
!refreshToken &&
!openId
) {
return null
}
return {
enabled: hasPayloadField(payload, 'enabled') ? payload.enabled !== false : current?.enabled !== false,
enabled: hasPayloadField(payload, 'enabled')
? payload.enabled !== false
: current?.enabled !== false,
sellerId,
shopId,
shopName,
customShopName,
authState: readConfigString(payload, 'authState', current?.authState || '', { allowBlank: true }),
authState: readConfigString(payload, 'authState', current?.authState || '', {
allowBlank: true,
}),
accessToken,
refreshToken,
accessTokenExpiresAt: readConfigString(payload, 'accessTokenExpiresAt', current?.accessTokenExpiresAt || '', { allowBlank: true }),
refreshTokenExpiresAt: readConfigString(payload, 'refreshTokenExpiresAt', current?.refreshTokenExpiresAt || '', { allowBlank: true }),
accessTokenExpiresAt: readConfigString(
payload,
'accessTokenExpiresAt',
current?.accessTokenExpiresAt || '',
{ allowBlank: true },
),
refreshTokenExpiresAt: readConfigString(
payload,
'refreshTokenExpiresAt',
current?.refreshTokenExpiresAt || '',
{ allowBlank: true },
),
openId,
grantedScopes: normalizeScopeText(readConfigString(payload, 'grantedScopes', current?.grantedScopes || '', { allowBlank: true })),
lastRefreshedAt: readConfigString(payload, 'lastRefreshedAt', current?.lastRefreshedAt || '', { allowBlank: true }),
lastRefreshError: readConfigString(payload, 'lastRefreshError', current?.lastRefreshError || '', { allowBlank: true }),
grantedScopes: normalizeScopeText(
readConfigString(payload, 'grantedScopes', current?.grantedScopes || '', {
allowBlank: true,
}),
),
lastRefreshedAt: readConfigString(payload, 'lastRefreshedAt', current?.lastRefreshedAt || '', {
allowBlank: true,
}),
lastRefreshError: readConfigString(
payload,
'lastRefreshError',
current?.lastRefreshError || '',
{ allowBlank: true },
),
...resolveShopSecretPatch(payload, current),
}
}
@@ -305,16 +350,14 @@ function resolveShopSecretPatch(
return patch
}
function readSecretString(
payload: JsonObject,
field: string,
fallback: string,
): string {
function readSecretString(payload: JsonObject, field: string, fallback: string): string {
const text = String(payload[field] || '').trim()
return text || fallback
}
function resolveAccessTokenStatus(config: Pick<KuaishouIndustrySourceConfig, 'accessToken' | 'accessTokenExpiresAt'>) {
function resolveAccessTokenStatus(
config: Pick<KuaishouIndustrySourceConfig, 'accessToken' | 'accessTokenExpiresAt'>,
) {
if (!config.accessToken) {
return {
status: 'missing',
@@ -94,23 +94,27 @@ function mapAdminNotificationConfig(config: JsonObject = {}) {
bark: {
enabled: bark.enabled !== false,
serverUrl: String(bark.serverUrl || 'https://api.day.app').trim(),
recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item: JsonObject) => ({
id: String(item.id || '').trim(),
name: String(item.name || '').trim(),
deviceKey: String(item.deviceKey || '').trim(),
deviceKeyMasked: maskSecret(item.deviceKey),
enabled: item.enabled !== false,
})),
recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map(
(item: JsonObject) => ({
id: String(item.id || '').trim(),
name: String(item.name || '').trim(),
deviceKey: String(item.deviceKey || '').trim(),
deviceKeyMasked: maskSecret(item.deviceKey),
enabled: item.enabled !== false,
}),
),
},
wpush: {
enabled: wpush.enabled !== false,
recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item: JsonObject) => ({
id: String(item.id || '').trim(),
name: String(item.name || '').trim(),
apiKey: String(item.apiKey || item.apikey || '').trim(),
apiKeyMasked: maskSecret(item.apiKey || item.apikey),
enabled: item.enabled !== false,
})),
recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map(
(item: JsonObject) => ({
id: String(item.id || '').trim(),
name: String(item.name || '').trim(),
apiKey: String(item.apiKey || item.apikey || '').trim(),
apiKeyMasked: maskSecret(item.apiKey || item.apikey),
enabled: item.enabled !== false,
}),
),
},
},
}
@@ -118,8 +122,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) {
function mapAdminScheduledJobsConfig(config: JsonObject = {}) {
const cloudtentaclesAccountMap = new Map(
listAdminCloudtentaclesMonitorAccounts()
.map((item) => [item.sourceKey, item]),
listAdminCloudtentaclesMonitorAccounts().map((item) => [item.sourceKey, item]),
)
return {
@@ -147,21 +150,23 @@ function listAdminCloudtentaclesMonitorAccounts() {
const sessionsConfig = getAllCloudtentaclesSessionStates()
const sessions: Record<string, JsonObject> = sessionsConfig.sessions || {}
return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => {
const sourceKey = String(source.key || '').trim()
const session = sessions[sourceKey] || {}
const label = String(source.label || source.username || sourceKey).trim() || sourceKey
return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : [])
.map((source) => {
const sourceKey = String(source.key || '').trim()
const session = sessions[sourceKey] || {}
const label = String(source.label || source.username || sourceKey).trim() || sourceKey
return {
sourceKey,
label,
enabled: source.enabled !== false,
username: String(source.username || '').trim(),
phoneMasked: maskPhone(source.phone || session.phone),
hasToken: Boolean(String(session.token || '').trim()),
loggedInAt: String(session.loggedInAt || '').trim(),
}
}).filter((item) => item.sourceKey)
return {
sourceKey,
label,
enabled: source.enabled !== false,
username: String(source.username || '').trim(),
phoneMasked: maskPhone(source.phone || session.phone),
hasToken: Boolean(String(session.token || '').trim()),
loggedInAt: String(session.loggedInAt || '').trim(),
}
})
.filter((item) => item.sourceKey)
}
function mapScheduledJobCloudtentaclesAccounts(
@@ -193,30 +198,35 @@ function mapScheduledJobCloudtentaclesAccounts(
},
] as const
})
.filter(Boolean) as Array<readonly [string, {
sourceKey: string
label: string
enabled: boolean
assetThreshold: number
}]>,
.filter(Boolean) as Array<
readonly [
string,
{
sourceKey: string
label: string
enabled: boolean
assetThreshold: number
},
]
>,
)
// 读配置时合并全部 kuaishou-lewan 账号,避免前端/任务只看到历史 default。
const merged = Array.from(cloudtentaclesAccountMap.values()).map((option) => {
const sourceKey = String(option.sourceKey || '').trim()
const configured = configuredMap.get(sourceKey)
return {
sourceKey,
label: String(configured?.label || option.label || sourceKey).trim(),
enabled: configured
? configured.enabled !== false
: option.enabled !== false,
assetThreshold: normalizeNonNegativeNumber(
configured?.assetThreshold,
defaultAssetThreshold,
),
}
}).filter((item) => item.sourceKey)
const merged = Array.from(cloudtentaclesAccountMap.values())
.map((option) => {
const sourceKey = String(option.sourceKey || '').trim()
const configured = configuredMap.get(sourceKey)
return {
sourceKey,
label: String(configured?.label || option.label || sourceKey).trim(),
enabled: configured ? configured.enabled !== false : option.enabled !== false,
assetThreshold: normalizeNonNegativeNumber(
configured?.assetThreshold,
defaultAssetThreshold,
),
}
})
.filter((item) => item.sourceKey)
const mergedKeys = new Set(merged.map((item) => item.sourceKey))
for (const [sourceKey, configured] of configuredMap.entries()) {
@@ -1,8 +1,5 @@
import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js'
import {
maskCode as maskCodeValue,
maskPhone as maskPhoneValue,
} from '../../utils/masking.js'
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../utils/masking.js'
import type { TaskRow } from '../../types/repository/rows.js'
@@ -31,7 +28,9 @@ export function isRecoverableTaskSessionCloseError(error: ErrorLike | null | und
}
export function normalizeManualDispatchOutcome(value: unknown): 'delivered' | 'failed' {
const normalized = String(value || '').trim().toLowerCase()
const normalized = String(value || '')
.trim()
.toLowerCase()
if (normalized === 'failed') {
return 'failed'
@@ -25,18 +25,13 @@ import {
refreshFulfillmentRole,
returnFulfillmentNumber,
} from '../../fulfillment/executors/registry.js'
import {
isKuaishouCloudTask,
normalizeKuaishouCloudFlow,
} from './kuaishou-cloud-helpers.js'
import { isKuaishouCloudTask, normalizeKuaishouCloudFlow } from './kuaishou-cloud-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../../types/admin/read-inputs.js'
import type {
AdminTaskKuaishouIndustryConsumeInput,
} from '../../../types/admin/write-inputs.js'
import type { AdminTaskKuaishouIndustryConsumeInput } from '../../../types/admin/write-inputs.js'
import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js'
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
@@ -443,8 +438,8 @@ export async function consumeAdminTaskKuaishouIndustryVoucher(
const taskContext = parseTaskContext(consumeTargetTask)
const hasCloudFulfillmentContext = Boolean(
taskContext.kuaishouCloudFulfillment &&
typeof taskContext.kuaishouCloudFulfillment === 'object' &&
!Array.isArray(taskContext.kuaishouCloudFulfillment),
typeof taskContext.kuaishouCloudFulfillment === 'object' &&
!Array.isArray(taskContext.kuaishouCloudFulfillment),
)
const flow = hasCloudFulfillmentContext
? normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
@@ -491,7 +486,9 @@ export async function consumeAdminTaskKuaishouIndustryVoucher(
return { task: mapTaskActionPayload(updatedTask || consumeTargetTask) }
}
async function getRequiredIndustryVoucherForTask(task: TaskRow): Promise<KuaishouIndustryVoucherRow> {
async function getRequiredIndustryVoucherForTask(
task: TaskRow,
): Promise<KuaishouIndustryVoucherRow> {
const vouchers = await listKuaishouIndustryVouchersByTaskId(task.id)
const firstVoucher = vouchers[0]
if (firstVoucher) {
@@ -499,7 +496,9 @@ async function getRequiredIndustryVoucherForTask(task: TaskRow): Promise<Kuaisho
}
const taskContext = parseTaskContext(task)
const voucherContext = normalizeAdminTaskIndustryVoucherContext(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)
@@ -539,12 +538,12 @@ async function getRequiredIndustryVoucherForTask(task: TaskRow): Promise<Kuaisho
}
function normalizeAdminTaskIndustryVoucherContext(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
}
async function resolveIndustryVouchersForOrder(platformOrderId: string): Promise<KuaishouIndustryVoucherRow[]> {
async function resolveIndustryVouchersForOrder(
platformOrderId: string,
): Promise<KuaishouIndustryVoucherRow[]> {
const normalizedOid = String(platformOrderId || '').trim()
if (!normalizedOid) {
return []
@@ -52,7 +52,9 @@ export type PreparedKuaishouCloudBindResource = {
bindUrl: string
}
export function resolvePersistedCloudtentaclesContext(sourceKeys: unknown[]): CloudtentaclesContext {
export function resolvePersistedCloudtentaclesContext(
sourceKeys: unknown[],
): CloudtentaclesContext {
try {
return resolvePersistedCloudtentaclesContextBySourceKeys(sourceKeys)
} catch (error) {
@@ -4,14 +4,8 @@ import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import {
canViewerCloseTask,
createAdminViewerContext,
} from '../admin-read-shared-helpers.js'
import {
getRequiredTask,
mapTaskActionPayload,
} from '../admin-task-read-helpers.js'
import { canViewerCloseTask, createAdminViewerContext } from '../admin-read-shared-helpers.js'
import { getRequiredTask, mapTaskActionPayload } from '../admin-task-read-helpers.js'
import type {
AdminEntityIdInput,
@@ -62,16 +56,21 @@ export async function closeAdminTask(
})
}
await createTaskEvent(task.id, 'task_closed', {
closedBy: session
? {
userId: session.userId,
username: session.username,
role: session.role,
}
: null,
claimTokenClosed: claimTokenId > 0,
}, now)
await createTaskEvent(
task.id,
'task_closed',
{
closedBy: session
? {
userId: session.userId,
username: session.username,
role: session.role,
}
: null,
claimTokenClosed: claimTokenId > 0,
},
now,
)
return {
task: mapTaskActionPayload(updatedTask),
@@ -69,7 +69,11 @@ export function getClaimIdentityFromTask(task: Partial<TaskRow> | null | undefin
}
export function hasClaimExpectedUid(taskOrContext: unknown): boolean {
if (taskOrContext && typeof taskOrContext === 'object' && 'context_json' in (taskOrContext as object)) {
if (
taskOrContext &&
typeof taskOrContext === 'object' &&
'context_json' in (taskOrContext as object)
) {
return Boolean(getClaimIdentityFromTask(taskOrContext as TaskRow).expectedUid)
}
return Boolean(getClaimIdentityFromContext(taskOrContext).expectedUid)
@@ -158,16 +162,11 @@ export function buildClaimIdentityAdminSummary(
role.name || binding.roleName || options.taskRoleName || '',
).trim()
const boundUid = useShipped ? shippedUid : liveBoundUid
const boundRoleName = useShipped
? shippedRoleName || liveBoundRoleName
: liveBoundRoleName
const boundRoleName = useShipped ? shippedRoleName || liveBoundRoleName : liveBoundRoleName
const ready = Boolean(identity.expectedUid)
const uidMatched = ready && boundUid ? isClaimUidMatched(identity.expectedUid, boundUid) : null
const liveMismatched =
useShipped &&
liveBoundUid &&
shippedUid &&
!isClaimUidMatched(shippedUid, liveBoundUid)
useShipped && liveBoundUid && shippedUid && !isClaimUidMatched(shippedUid, liveBoundUid)
let note = '旧单或未提交 UID:用户须先打开领取页填写 UID,否则禁止自动发货'
if (ready) {
@@ -90,8 +90,5 @@ test('assertBoundUidMatchesExpected 在不匹配时抛错', () => {
})
test('assertClaimExpectedUidReady 要求 claimIdentity', () => {
assert.throws(
() => assertClaimExpectedUidReady({ context_json: '{}' }),
/填写游戏 UID/,
)
assert.throws(() => assertClaimExpectedUidReady({ context_json: '{}' }), /填写游戏 UID/)
})
@@ -42,9 +42,7 @@ export function resolveClaimTokenExpiration(
tokenTtlHours: unknown,
): string | null {
const ttlHours = Number(tokenTtlHours)
return Number.isFinite(ttlHours) && ttlHours > 0
? addHours(createdAt, ttlHours)
: null
return Number.isFinite(ttlHours) && ttlHours > 0 ? addHours(createdAt, ttlHours) : null
}
export function buildClaimUrl(token: string): string {
@@ -12,7 +12,7 @@ import { buildClaimIdentityPayload, getClaimIdentityFromContext } from './claim-
import { resolveKuaishouFeifeiH5UrlWithUid } from '../fulfillment/kuaishou-feifei/index.js'
import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js'
import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED])
type ClaimContext = {
@@ -148,16 +148,21 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
kuaishouFeifei: null as null,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
function buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
function buildAffiliateDashClaimDetailPayload({
claimToken,
task,
order,
orderItem,
}: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const flow = normalizeAffiliateDashFlow(context.affiliateDash)
@@ -169,11 +174,13 @@ function buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderIt
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
}],
items: [
{
cloudSkuId: 0,
name: productTitle,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
}
return {
@@ -218,16 +225,21 @@ function buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderIt
affiliateDash: flow,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
function buildKuaishouFeifeiClaimDetailPayload({
claimToken,
task,
order,
orderItem,
}: ClaimContext) {
const context = parseTaskContext(task)
const claimIdentity = buildClaimIdentityPayload(context.claimIdentity)
const expectedUid = getClaimIdentityFromContext(context).expectedUid
@@ -237,11 +249,13 @@ function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderI
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [{
cloudSkuId: 0,
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
}],
items: [
{
cloudSkuId: 0,
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
],
}
return {
@@ -285,11 +299,11 @@ function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderI
kuaishouFeifei: flow,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
@@ -362,13 +376,16 @@ function buildClaimProductPayload(
quantity: item.quantity,
}))
.filter((item) => item.name)
const normalizedDeliveryItems = deliveryItems.length > 0
? mergeClaimProductItems(deliveryItems)
: [{
cloudSkuId: 0,
name: displaySkuName,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
}]
const normalizedDeliveryItems =
deliveryItems.length > 0
? mergeClaimProductItems(deliveryItems)
: [
{
cloudSkuId: 0,
name: displaySkuName,
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
},
]
return {
title: displaySkuName,
@@ -452,7 +469,9 @@ export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow)
binding: {
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
cloudSourceKeys: Array.isArray(binding.cloudSourceKeys)
? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean)
? binding.cloudSourceKeys
.map((value: unknown) => String(value || '').trim())
.filter(Boolean)
: [],
resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
skuId: Number(binding.skuId || 0) || 0,
@@ -566,7 +585,9 @@ function normalizeClaimDeliveryItems(value: unknown, binding: JsonObject) {
const rawItems = Array.isArray(value) ? value : []
const items = rawItems
.map((item) => normalizeClaimDeliveryItem(item))
.filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => Boolean(item))
.filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } =>
Boolean(item),
)
if (items.length > 0) {
return mergeClaimDeliveryItems(items)
@@ -577,11 +598,13 @@ function normalizeClaimDeliveryItems(value: unknown, binding: JsonObject) {
return []
}
return [{
cloudSkuId,
cloudSkuName: String(binding.skuName || '').trim(),
quantity: 1,
}]
return [
{
cloudSkuId,
cloudSkuName: String(binding.skuName || '').trim(),
quantity: 1,
},
]
}
function normalizeClaimDeliveryItem(value: unknown) {
@@ -633,12 +656,13 @@ async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) {
let nextTask = task
if (!isTaskFinalStatus(task.task_status)) {
nextTask = await updateTask(task.id, {
task_status: TASK_STATUS.EXPIRED,
user_action_status: TASK_STATUS.EXPIRED,
last_error: '领取链接已过期',
updated_at: now,
}) || task
nextTask =
(await updateTask(task.id, {
task_status: TASK_STATUS.EXPIRED,
user_action_status: TASK_STATUS.EXPIRED,
last_error: '领取链接已过期',
updated_at: now,
})) || task
}
return {
@@ -61,7 +61,9 @@ async function requireExecutorAction<T>(
}
function isKuaishouCloudBindingReady(flow: ReturnType<typeof normalizeKuaishouCloudFlow>) {
return flow.binding.prepareStatus === 'ready' && Boolean(String(flow.binding.bindUrl || '').trim())
return (
flow.binding.prepareStatus === 'ready' && Boolean(String(flow.binding.bindUrl || '').trim())
)
}
/**
@@ -160,9 +162,10 @@ async function verifyIndustryVoucherTicket(
shopId: context.order.shop_id,
shopName: context.order.shop_name,
autoConsumeEnabled: true,
consumedAt: voucherContext.status === 'CONSUMED'
? voucherContext.consumedAt || flow.consume.consumedAt || now
: flow.consume.consumedAt,
consumedAt:
voucherContext.status === 'CONSUMED'
? voucherContext.consumedAt || flow.consume.consumedAt || now
: flow.consume.consumedAt,
},
certInfo: {
certExpireType,
@@ -242,8 +245,10 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
// 已核销也要走 verify:内部会补 prepare 绑定资源
if (flow.consume.status !== 'success' || !isKuaishouCloudBindingReady(flow)) {
const now = nowIso()
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now)
.catch(() => null)
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(
context,
now,
).catch(() => null)
if (prepared) {
return prepared
}
@@ -255,15 +260,14 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const needsIndustryVoucherPrepare =
hasUsableIndustryVoucher(taskContext) &&
(
flow.ticket.status !== 'verified' ||
!isKuaishouCloudBindingReady(flow)
)
(flow.ticket.status !== 'verified' || !isKuaishouCloudBindingReady(flow))
if (needsIndustryVoucherPrepare) {
const now = nowIso()
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now)
.catch(() => null)
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(
context,
now,
).catch(() => null)
if (prepared) {
return prepared
}
@@ -273,7 +277,8 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise<Claim
if (!isKuaishouCloudMockTask(task)) {
const latestFlow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
if (
(latestFlow.ticket.status === 'verified' || hasUsableIndustryVoucher(parseTaskContext(task))) &&
(latestFlow.ticket.status === 'verified' ||
hasUsableIndustryVoucher(parseTaskContext(task))) &&
!isKuaishouCloudBindingReady(latestFlow)
) {
task = await ensureKuaishouCloudBindingPrepared(task, {
@@ -816,8 +821,8 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise<TaskRow | n
// (不依赖平台 mismatch 字段,防止平台未标记但实际账号不一致的情况)。
const localMismatch = Boolean(
boundAccount &&
expectedGameAccount &&
normalizeUid(boundAccount) !== normalizeUid(expectedGameAccount),
expectedGameAccount &&
normalizeUid(boundAccount) !== normalizeUid(expectedGameAccount),
)
const nextFlow = {
...flow,
@@ -923,10 +928,11 @@ function hasUsableIndustryVoucher(context: JsonObject = {}) {
}
function normalizeIndustryVoucherContext(value: unknown): JsonObject {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const status = String(source.status || 'UNUSED').trim().toUpperCase()
const source =
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
const status = String(source.status || 'UNUSED')
.trim()
.toUpperCase()
return {
...source,
@@ -1,9 +1,6 @@
import fs from 'node:fs'
import {
listAppConfigEntries,
upsertAppConfigEntry,
} from '../../repositories/app-config-repo.js'
import { listAppConfigEntries, upsertAppConfigEntry } from '../../repositories/app-config-repo.js'
import { readJsonFile } from '../../utils/json-file-store.js'
type NormalizeJsonValue<T> = (value: unknown) => T
@@ -67,8 +67,8 @@ const JSON_CONFIG_MIGRATION_ITEMS: JsonConfigMigrationItem[] = [
]
export async function migrateJsonConfigFilesToDatabase() {
const migrated: Array<{ configKey: string, filePath: string }> = []
const skipped: Array<{ configKey: string, filePath: string, reason: string }> = []
const migrated: Array<{ configKey: string; filePath: string }> = []
const skipped: Array<{ configKey: string; filePath: string; reason: string }> = []
for (const item of JSON_CONFIG_MIGRATION_ITEMS) {
const filePath = path.join(DATA_DIR, item.fileName)
@@ -65,7 +65,11 @@ export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean
if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') {
return true
}
if (String(env.ENABLE_DEV_MOCK || '').trim().toLowerCase() === 'true') {
if (
String(env.ENABLE_DEV_MOCK || '')
.trim()
.toLowerCase() === 'true'
) {
return true
}
return !isProductionLike(env)
@@ -111,14 +115,16 @@ export function getDevMockStatus() {
}
}
export async function createLewanMockClaim(input: {
step?: unknown
orderNo?: unknown
productNo?: unknown
uid?: unknown
items?: unknown
frontendBaseUrl?: unknown
} = {}): Promise<DevMockCreateResult> {
export async function createLewanMockClaim(
input: {
step?: unknown
orderNo?: unknown
productNo?: unknown
uid?: unknown
items?: unknown
frontendBaseUrl?: unknown
} = {},
): Promise<DevMockCreateResult> {
assertDevMockEnabled()
const step = normalizeLewanStep(input.step)
@@ -192,7 +198,10 @@ export async function createLewanMockClaim(input: {
})
if (!task) {
throw createHttpError('履约任务创建失败', { statusCode: 500, errorCode: 'dev_mock_task_failed' })
throw createHttpError('履约任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_task_failed',
})
}
const claimToken = await createTaskClaimToken(task.id)
@@ -223,15 +232,17 @@ export async function createLewanMockClaim(input: {
})
}
export async function createFeifeiMockClaim(input: {
step?: unknown
orderNo?: unknown
productName?: unknown
productCode?: unknown
uid?: unknown
h5Url?: unknown
frontendBaseUrl?: unknown
} = {}): Promise<DevMockCreateResult> {
export async function createFeifeiMockClaim(
input: {
step?: unknown
orderNo?: unknown
productName?: unknown
productCode?: unknown
uid?: unknown
h5Url?: unknown
frontendBaseUrl?: unknown
} = {},
): Promise<DevMockCreateResult> {
assertDevMockEnabled()
const step = normalizeFeifeiStep(input.step)
@@ -296,7 +307,12 @@ export async function createFeifeiMockClaim(input: {
? TASK_STATUS.MANUAL_REVIEW
: TASK_STATUS.LINK_GENERATED,
deliveryStatus: step === 'completed' ? 'delivered' : 'pending',
resultCode: step === 'completed' ? 'kuaishou_feifei_completed' : step === 'failed' ? 'kuaishou_feifei_status_40' : '',
resultCode:
step === 'completed'
? 'kuaishou_feifei_completed'
: step === 'failed'
? 'kuaishou_feifei_status_40'
: '',
resultMessage: rechargeStatusLabel,
claimToken: '',
claimExpiresAt: null,
@@ -349,7 +365,10 @@ export async function createFeifeiMockClaim(input: {
})
if (!task) {
throw createHttpError('履约任务创建失败', { statusCode: 500, errorCode: 'dev_mock_task_failed' })
throw createHttpError('履约任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_task_failed',
})
}
const claimToken = await createTaskClaimToken(task.id)
@@ -383,14 +402,16 @@ type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'faile
* 生成 affiliate-dash 领取 mock:不调用真实 affiliate-dash 平台。
* 上下文带 mock 标记,sync/refresh/submit 全部短路,领取页可走完四步。
*/
export async function createAffiliateDashMockClaim(input: {
step?: unknown
orderNo?: unknown
productName?: unknown
productSku?: unknown
uid?: unknown
frontendBaseUrl?: unknown
} = {}): Promise<DevMockCreateResult> {
export async function createAffiliateDashMockClaim(
input: {
step?: unknown
orderNo?: unknown
productName?: unknown
productSku?: unknown
uid?: unknown
frontendBaseUrl?: unknown
} = {},
): Promise<DevMockCreateResult> {
assertDevMockEnabled()
const step = normalizeAffiliateDashStep(input.step)
@@ -457,11 +478,7 @@ export async function createAffiliateDashMockClaim(input: {
? TASK_STATUS.REDEEMING
: TASK_STATUS.LINK_GENERATED,
deliveryStatus:
step === 'completed'
? 'delivered'
: step === 'submitted'
? 'delivering'
: 'pending',
step === 'completed' ? 'delivered' : step === 'submitted' ? 'delivering' : 'pending',
resultCode:
step === 'completed'
? 'affiliate_dash_delivered'
@@ -541,7 +558,10 @@ export async function createAffiliateDashMockClaim(input: {
})
if (!task) {
throw createHttpError('履约任务创建失败', { statusCode: 500, errorCode: 'dev_mock_task_failed' })
throw createHttpError('履约任务创建失败', {
statusCode: 500,
errorCode: 'dev_mock_task_failed',
})
}
const claimToken = await createTaskClaimToken(task.id)
@@ -573,9 +593,11 @@ export async function createAffiliateDashMockClaim(input: {
* 生成电子凭证列表测试数据,不调用快手接口。
* 覆盖未使用、已核销、已销毁和发码失败等运营页面常见状态。
*/
export async function createKuaishouIndustryVoucherMockData(input: {
sellerId?: unknown
} = {}): Promise<DevMockKuaishouIndustryVoucherResult> {
export async function createKuaishouIndustryVoucherMockData(
input: {
sellerId?: unknown
} = {},
): Promise<DevMockKuaishouIndustryVoucherResult> {
assertDevMockEnabled()
const now = nowIso()
@@ -668,11 +690,13 @@ export async function createKuaishouIndustryVoucherMockData(input: {
/**
* 生成 91 查询请求体(带签名),可选对本机发起查询。
*/
export async function buildOpen91QueryMock(input: {
orderNo?: unknown
execute?: unknown
baseUrl?: unknown
} = {}) {
export async function buildOpen91QueryMock(
input: {
orderNo?: unknown
execute?: unknown
baseUrl?: unknown
} = {},
) {
assertDevMockEnabled()
const orderNo = String(input.orderNo || '').trim()
@@ -767,7 +791,10 @@ async function ensureProfile(profileKey: string, name: string, requiresClaim: bo
})
if (!profile) {
throw createHttpError('履约配置创建失败', { statusCode: 500, errorCode: 'dev_mock_profile_failed' })
throw createHttpError('履约配置创建失败', {
statusCode: 500,
errorCode: 'dev_mock_profile_failed',
})
}
return profile
}
@@ -853,19 +880,19 @@ async function createBaseOrderItem(input: {
},
}
: {
source: OPEN_91_PROVIDER,
orderNo: input.orderNo,
productNo: input.productNo,
productName: input.productName,
shopId: input.consumeShopId,
cloudtentacles: {
matchMode: 'mock',
normalizedProductName: input.productName,
cloudSourceKeys: ['mock-cloudtentacles'],
resolvedSourceKey: 'mock-cloudtentacles',
deliveryItems: input.deliveryItems,
},
}
source: OPEN_91_PROVIDER,
orderNo: input.orderNo,
productNo: input.productNo,
productName: input.productName,
shopId: input.consumeShopId,
cloudtentacles: {
matchMode: 'mock',
normalizedProductName: input.productName,
cloudSourceKeys: ['mock-cloudtentacles'],
resolvedSourceKey: 'mock-cloudtentacles',
deliveryItems: input.deliveryItems,
},
}
const [orderItem] = await replaceOrderItems(input.orderId, [
{
@@ -885,7 +912,10 @@ async function createBaseOrderItem(input: {
])
if (!orderItem) {
throw createHttpError('订单商品创建失败', { statusCode: 500, errorCode: 'dev_mock_item_failed' })
throw createHttpError('订单商品创建失败', {
statusCode: 500,
errorCode: 'dev_mock_item_failed',
})
}
return orderItem
}
@@ -931,9 +961,7 @@ function buildLewanMockContext(input: {
configId: `mock:${input.productName}`,
internalSkuCode: input.productName,
internalSkuName: input.productName,
deliveryItems: input.deliveryItems.length
? input.deliveryItems
: [primaryItem],
deliveryItems: input.deliveryItems.length ? input.deliveryItems : [primaryItem],
mock: {
enabled: true,
orderNo: input.orderNo,
@@ -960,9 +988,7 @@ function buildLewanMockContext(input: {
vnKey: '1',
vnId: roleReady ? 900001 : 0,
vnPhone: roleReady ? '13800000000' : '',
bindUrl: roleReady
? `https://example.com/mock-kuaishou-cloud-bind/${input.orderNo}`
: '',
bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${input.orderNo}` : '',
bindPreparedAt: roleReady ? input.timestamp : null,
bindExpiresAt: roleReady ? addHours(input.timestamp, 24) : null,
bindProbeAt: roleReady ? input.timestamp : null,
@@ -1190,7 +1216,10 @@ function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) {
return [`打开领取链接,Step1 填 UID${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码']
}
if (step === 'bind') {
return [`已预填 UID=${uid},绑定二维码为 mock 生成(扫描无效)`, '正常流程:扫码完成真实绑定后自动进入下一步']
return [
`已预填 UID=${uid},绑定二维码为 mock 生成(扫描无效)`,
'正常流程:扫码完成真实绑定后自动进入下一步',
]
}
if (step === 'submitted') {
return ['已模拟绑定成功,可点「提交发货」(mock 直接模拟发货成功)']
@@ -50,10 +50,7 @@ function buildTask(overrides: Partial<TaskRow> = {}): TaskRow {
test('buildAffiliateDashClientOrderNo uses the platform order number for a single task', () => {
const task = buildTask()
assert.equal(
buildAffiliateDashClientOrderNo(task, [task]),
'2622300001260431',
)
assert.equal(buildAffiliateDashClientOrderNo(task, [task]), '2622300001260431')
})
test('buildAffiliateDashClientOrderNo appends a stable position for split affiliate-dash tasks', () => {
@@ -52,10 +52,13 @@ export async function prepareAffiliateDashTask(task: TaskRow) {
}
if (!flow.sku) {
throw createHttpError('affiliate-dash 商品 sku 缺失,请配置 91 商品 → affiliate_dash sku 映射', {
statusCode: 409,
errorCode: 'affiliate_dash_sku_missing',
})
throw createHttpError(
'affiliate-dash 商品 sku 缺失,请配置 91 商品 → affiliate_dash sku 映射',
{
statusCode: 409,
errorCode: 'affiliate_dash_sku_missing',
},
)
}
const siblingTasks = await listTasksByOrderId(task.order_id)
@@ -97,13 +100,18 @@ export async function prepareAffiliateDashTask(task: TaskRow) {
)
}
if (!consumeResult.ok) {
logIntegration('[affiliate-dash]', '建单成功但电子凭证核销失败,delivered 回调将兜底重试', {
taskId: task.id,
orderNo: order.orderNo,
voucherCount: consumeResult.vouchers.length,
failedCount: consumeResult.failed.length,
errorMessage: consumeResult.failed[0]?.errorMessage || '',
}, { level: 'warn' })
logIntegration(
'[affiliate-dash]',
'建单成功但电子凭证核销失败,delivered 回调将兜底重试',
{
taskId: task.id,
orderNo: order.orderNo,
voucherCount: consumeResult.vouchers.length,
failedCount: consumeResult.failed.length,
errorMessage: consumeResult.failed[0]?.errorMessage || '',
},
{ level: 'warn' },
)
}
} else {
nextFlow.consumeStatus = 'not_required'
@@ -231,11 +239,16 @@ export async function syncAffiliateDashTaskStatus(
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
lastError = resultMessage
nextFlow.consumeStatus = 'failed'
logIntegration('[affiliate-dash]', 'affiliate-dash 履约完成但核销失败', {
taskId: task.id,
orderNo: flow.orderNo,
errorMessage: resultMessage,
}, { level: 'warn' })
logIntegration(
'[affiliate-dash]',
'affiliate-dash 履约完成但核销失败',
{
taskId: task.id,
orderNo: flow.orderNo,
errorMessage: resultMessage,
},
{ level: 'warn' },
)
}
break
}
@@ -256,7 +269,12 @@ export async function syncAffiliateDashTaskStatus(
}
const isAlreadyTerminal = (
[TASK_STATUS.REDEEMED, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED, TASK_STATUS.FAILED] as string[]
[
TASK_STATUS.REDEEMED,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.CLOSED,
TASK_STATUS.FAILED,
] as string[]
).includes(task.task_status)
// 终态保护:已收敛成功的任务,非 delivered 状态不允许降级(如轮询时平台详情短暂返回
// delivering/paid 会把 REDEEMED 打回 REDEEMING,导致结果页闪烁/倒退)。
@@ -316,9 +334,8 @@ export type AffiliateDashFlow = {
}
export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const source =
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
return {
flowType: 'affiliate_dash',
@@ -397,10 +414,15 @@ async function preflightAffiliateDashWallet(sku: string) {
if ((error as { errorCode?: string })?.errorCode === 'affiliate_dash_wallet_not_enough') {
throw error
}
logIntegration('[affiliate-dash]', '余额预检失败(忽略,继续下单)', {
sku,
error: error instanceof Error ? error.message : String(error),
}, { level: 'warn' })
logIntegration(
'[affiliate-dash]',
'余额预检失败(忽略,继续下单)',
{
sku,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
}
}
@@ -6,11 +6,13 @@ import { resolveTaskDeliveryLink } from './delivery-link-service.js'
import type { TaskRow } from '../../types/repository/rows.js'
test('resolveTaskDeliveryLink 为 cloud 任务返回内部领取链接', async () => {
const result = await resolveTaskDeliveryLink(createTask({
executor_key: 'kuaishou_ct_assisted',
primary_claim_token: 'cloud-token',
primary_claim_expires_at: '2026-07-09T00:00:00.000Z',
}))
const result = await resolveTaskDeliveryLink(
createTask({
executor_key: 'kuaishou_ct_assisted',
primary_claim_token: 'cloud-token',
primary_claim_expires_at: '2026-07-09T00:00:00.000Z',
}),
)
assert.deepEqual(result, {
claimUrl: buildClaimUrl('cloud-token'),
@@ -19,11 +21,13 @@ test('resolveTaskDeliveryLink 为 cloud 任务返回内部领取链接', async (
})
test('resolveTaskDeliveryLink 兼容历史 kuaishou-industry cloud 任务', async () => {
const result = await resolveTaskDeliveryLink(createTask({
executor_key: 'kuaishou-industry',
claim_token: 'industry-token',
claim_expires_at: '2026-07-09T08:00:00.000Z',
}))
const result = await resolveTaskDeliveryLink(
createTask({
executor_key: 'kuaishou-industry',
claim_token: 'industry-token',
claim_expires_at: '2026-07-09T08:00:00.000Z',
}),
)
assert.deepEqual(result, {
claimUrl: buildClaimUrl('industry-token'),
@@ -32,18 +36,20 @@ test('resolveTaskDeliveryLink 兼容历史 kuaishou-industry cloud 任务', asyn
})
test('resolveTaskDeliveryLink 为 feifei 任务返回本站领取链接', async () => {
const result = await resolveTaskDeliveryLink(createTask({
executor_key: 'kuaishou_feifei',
primary_claim_token: 'feifei-token',
primary_claim_expires_at: '2026-07-09T12:00:00.000Z',
context_json: {
kuaishouFeifei: {
h5: {
rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long',
const result = await resolveTaskDeliveryLink(
createTask({
executor_key: 'kuaishou_feifei',
primary_claim_token: 'feifei-token',
primary_claim_expires_at: '2026-07-09T12:00:00.000Z',
context_json: {
kuaishouFeifei: {
h5: {
rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long',
},
},
},
},
}))
}),
)
assert.deepEqual(result, {
claimUrl: buildClaimUrl('feifei-token'),
@@ -52,9 +58,11 @@ test('resolveTaskDeliveryLink 为 feifei 任务返回本站领取链接', async
})
test('resolveTaskDeliveryLink 对人工履约任务返回 null', async () => {
const result = await resolveTaskDeliveryLink(createTask({
executor_key: 'manual_dispatch',
}))
const result = await resolveTaskDeliveryLink(
createTask({
executor_key: 'manual_dispatch',
}),
)
assert.equal(result, null)
})
@@ -4,8 +4,6 @@ import type { TaskRow } from '../../types/repository/rows.js'
export type TaskDeliveryLink = FulfillmentDeliveryLink
export async function resolveTaskDeliveryLink(
task: TaskRow,
): Promise<TaskDeliveryLink | null> {
export async function resolveTaskDeliveryLink(task: TaskRow): Promise<TaskDeliveryLink | null> {
return resolveFulfillmentDeliveryLink(task)
}
@@ -1,8 +1,5 @@
import { buildClaimUrl } from '../../claim/claim-service.js'
import {
shouldEnsureKuaishouCloudClaimLink,
TASK_STATUS,
} from '../../../domain/task-status.js'
import { shouldEnsureKuaishouCloudClaimLink, TASK_STATUS } from '../../../domain/task-status.js'
import {
confirmKuaishouCloudTaskRole,
dispatchKuaishouCloudFulfillmentTask,
@@ -95,9 +92,7 @@ async function prepareBinding(
options: FulfillmentActionOptions = {},
): Promise<FulfillmentActionResult> {
// 后台/显式 force:旧号可被 CT 回收,忽略退号失败并取新号+新绑链
const force =
options.force === true ||
String(options.source || '').includes('admin_')
const force = options.force === true || String(options.source || '').includes('admin_')
const result = await prepareKuaishouCloudFulfillmentTask(task, {
source: options.source || 'executor_prepare_binding',
actor: options.actor,
@@ -78,51 +78,30 @@ async function runExecutorAction(
return handler(task, options)
}
export function prepareFulfillmentBinding(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function prepareFulfillmentBinding(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'prepareBinding', options)
}
export function rebindFulfillmentRole(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function rebindFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'rebindRole', options)
}
export function refreshFulfillmentRole(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function refreshFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'refreshRole', options)
}
export function confirmFulfillmentRole(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function confirmFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'confirmRole', options)
}
export function redeemFulfillmentTask(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function redeemFulfillmentTask(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'redeemTask', options)
}
export function dispatchFulfillmentTask(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function dispatchFulfillmentTask(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'dispatchTask', options)
}
export function returnFulfillmentNumber(
task: TaskRow,
options: FulfillmentActionOptions = {},
) {
export function returnFulfillmentNumber(task: TaskRow, options: FulfillmentActionOptions = {}) {
return runExecutorAction(task, 'returnNumber', options)
}
@@ -10,7 +10,8 @@ export const FULFILLMENT_EXECUTOR_KEYS = {
} as const
export type FulfillmentExecutorKey =
(typeof FULFILLMENT_EXECUTOR_KEYS)[keyof typeof FULFILLMENT_EXECUTOR_KEYS] | (string & {})
| (typeof FULFILLMENT_EXECUTOR_KEYS)[keyof typeof FULFILLMENT_EXECUTOR_KEYS]
| (string & {})
export type FulfillmentDeliveryLink = {
claimUrl: string
@@ -53,10 +54,7 @@ export type FulfillmentActionResult = {
export type FulfillmentExecutor = {
key: FulfillmentExecutorKey
preparePaidTask?: (
task: TaskRow,
deps: FulfillmentPrepareDeps,
) => Promise<TaskRow | null>
preparePaidTask?: (task: TaskRow, deps: FulfillmentPrepareDeps) => Promise<TaskRow | null>
resolveDeliveryLink?: (task: TaskRow) => Promise<FulfillmentDeliveryLink | null>
/** lewan:准备绑定资源(虚拟号 / bindUrl) */
prepareBinding?: (
@@ -104,8 +102,10 @@ export function normalizeExecutorKey(value: unknown): FulfillmentExecutorKey {
export function isKuaishouCloudExecutor(value: unknown): boolean {
const executorKey = normalizeExecutorKey(value)
return executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD ||
return (
executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD ||
executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY
)
}
export function isKuaishouFeifeiExecutor(value: unknown): boolean {
@@ -65,7 +65,8 @@ test('selectCloudtentaclesSourceForFulfillment 无号码时忽略残留固定账
},
},
{
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
resolveContextBySourceKeys: (sourceKeys) =>
contexts[String(sourceKeys[0]) as keyof typeof contexts],
listSourceLoadStats: async () => [
{ sourceKey: 'account-a', activeCount: 20, redeemingCount: 0 },
{ sourceKey: 'account-b', activeCount: 1, redeemingCount: 0 },
@@ -86,7 +87,8 @@ test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账
const selection = await selectCloudtentaclesSourceForFulfillment(
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
{
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
resolveContextBySourceKeys: (sourceKeys) =>
contexts[String(sourceKeys[0]) as keyof typeof contexts],
listSourceLoadStats: async () => [],
listVirtualNumbers: async (payload) => {
const count = payload.sourceKey === 'account-a' ? 20 : 3
@@ -115,12 +117,14 @@ test('selectCloudtentaclesSourceForFulfillment 不把 status=0 的空闲号码
const selection = await selectCloudtentaclesSourceForFulfillment(
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
{
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
resolveContextBySourceKeys: (sourceKeys) =>
contexts[String(sourceKeys[0]) as keyof typeof contexts],
listSourceLoadStats: async () => [],
listVirtualNumbers: async (payload) => {
const items = payload.sourceKey === 'account-a'
? Array.from({ length: 20 }, (_, id) => ({ id: id + 1, status: 0 }))
: Array.from({ length: 3 }, (_, id) => ({ id: id + 1, status: 1 }))
const items =
payload.sourceKey === 'account-a'
? Array.from({ length: 20 }, (_, id) => ({ id: id + 1, status: 0 }))
: Array.from({ length: 3 }, (_, id) => ({ id: id + 1, status: 1 }))
return {
baseUrl: 'https://cloud.example.com',
key: '1',
@@ -145,7 +149,8 @@ test('selectCloudtentaclesSourceForFulfillment 真实占用优先于数据库历
const selection = await selectCloudtentaclesSourceForFulfillment(
{ binding: { cloudSourceKeys: ['account-a', 'account-b'] } },
{
resolveContextBySourceKeys: (sourceKeys) => contexts[String(sourceKeys[0]) as keyof typeof contexts],
resolveContextBySourceKeys: (sourceKeys) =>
contexts[String(sourceKeys[0]) as keyof typeof contexts],
listSourceLoadStats: async () => [
{ sourceKey: 'account-a', activeCount: 99, redeemingCount: 10 },
{ sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 },
@@ -10,14 +10,8 @@ import {
} from './domain.js'
test('isKuaishouCloudDispatchSucceeded 识别 dispatch.success', () => {
assert.equal(
isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'success' } }),
true,
)
assert.equal(
isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'pending' } }),
false,
)
assert.equal(isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'success' } }), true)
assert.equal(isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'pending' } }), false)
})
test('isKuaishouCloudBindingMutationFrozendispatch.success 即使 status=waiting_binding 也冻结', () => {
@@ -1,5 +1,5 @@
import { createHttpError } from "../../../utils/http.js";
import { normalizeProductName } from "../product-resolution-service.js";
import { createHttpError } from '../../../utils/http.js'
import { normalizeProductName } from '../product-resolution-service.js'
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
@@ -7,107 +7,95 @@ import {
generateCloudtentaclesLoginCode,
getCloudtentaclesBindUrl,
verifyCloudtentaclesLoginCode,
} from "../../platforms/cloudtentacles/virtual-number-service.js";
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
import { logInfo } from "../../../utils/logger.js";
} from '../../platforms/cloudtentacles/virtual-number-service.js'
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from './domain.js'
import { logInfo } from '../../../utils/logger.js'
/** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */
const APPOINT_QUOTA_COOLDOWN_MS = 60_000;
const appointQuotaCooldowns = new Map<string, number>();
const APPOINT_QUOTA_COOLDOWN_MS = 60_000
const appointQuotaCooldowns = new Map<string, number>()
export function resolveKuaishouCloudBindingResources(
flow: JsonObject,
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {}
{ skuItems = [], knapsackItems = [] }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {},
) {
const normalizedSkuItems = Array.isArray(skuItems)
? skuItems.filter(isCloudSkuLikeItem)
: [];
const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : []
const normalizedKnapsackItems = Array.isArray(knapsackItems)
? knapsackItems.filter(isCloudSkuLikeItem)
: [];
const currentSkuId = Number(flow?.binding?.skuId || 0) || 0;
const currentSkuName = String(flow?.binding?.skuName || "").trim();
const nameCandidates = collectKuaishouCloudNameCandidates(flow);
: []
const currentSkuId = Number(flow?.binding?.skuId || 0) || 0
const currentSkuName = String(flow?.binding?.skuName || '').trim()
const nameCandidates = collectKuaishouCloudNameCandidates(flow)
const skuItemById =
currentSkuId > 0
? normalizedSkuItems.find(
(item) => Number(item.id || 0) === currentSkuId
) || null
: null;
? normalizedSkuItems.find((item) => Number(item.id || 0) === currentSkuId) || null
: null
const knapsackItemById =
currentSkuId > 0
? normalizedKnapsackItems.find(
(item) => Number(item.id || 0) === currentSkuId
) || null
: null;
? normalizedKnapsackItems.find((item) => Number(item.id || 0) === currentSkuId) || null
: null
if (skuItemById || knapsackItemById) {
const matchedItem = skuItemById || knapsackItemById;
const matchedItem = skuItemById || knapsackItemById
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
skuName: String(currentSkuName || matchedItem?.name || '').trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: skuItemById,
knapsackItem: knapsackItemById,
resolvedByName: false,
};
}
}
const matchedSkuItem = findCloudItemByNames(
normalizedSkuItems,
nameCandidates
);
const matchedSkuItem = findCloudItemByNames(normalizedSkuItems, nameCandidates)
const matchedKnapsackItem = findCloudItemByNames(
normalizedKnapsackItems,
nameCandidates,
matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0
);
const matchedItem = matchedSkuItem || matchedKnapsackItem;
matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0,
)
const matchedItem = matchedSkuItem || matchedKnapsackItem
return {
skuId: Number(matchedItem?.id || 0) || 0,
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
skuName: String(currentSkuName || matchedItem?.name || '').trim(),
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
skuItem: matchedSkuItem,
knapsackItem: matchedKnapsackItem,
resolvedByName: Boolean(matchedItem),
};
}
}
export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) {
return [KUAISHOU_CLOUD_FIXED_VN_KEY];
return [KUAISHOU_CLOUD_FIXED_VN_KEY]
}
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
const { cloudContext = {}, vnKeyCandidates = [] } = input;
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [];
const sourceKey = String(cloudContext.resolvedSourceKey || "").trim();
let lastError = null;
const { cloudContext = {}, vnKeyCandidates = [] } = input
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : []
const sourceKey = String(cloudContext.resolvedSourceKey || '').trim()
let lastError = null
for (const vnKey of candidates) {
let vnId = 0;
let vnPhone = "";
const cooldownKey = `${sourceKey}|${vnKey}`;
let vnId = 0
let vnPhone = ''
const cooldownKey = `${sourceKey}|${vnKey}`
const cooldownUntil = appointQuotaCooldowns.get(cooldownKey) || 0;
const cooldownUntil = appointQuotaCooldowns.get(cooldownKey) || 0
if (cooldownUntil > Date.now()) {
throw createHttpError(
"账号虚拟号配额已满,请先退回已占用号码或稍后重试",
{
statusCode: 409,
errorCode: "cloudtentacles_vn_quota_cooldown",
}
);
throw createHttpError('账号虚拟号配额已满,请先退回已占用号码或稍后重试', {
statusCode: 409,
errorCode: 'cloudtentacles_vn_quota_cooldown',
})
}
try {
const appointed = await appointCloudtentaclesVirtualNumber({
...cloudContext,
key: vnKey,
});
vnId = Number(appointed.item?.id || 0);
vnPhone = String(appointed.item?.phone || "").trim();
})
vnId = Number(appointed.item?.id || 0)
vnPhone = String(appointed.item?.phone || '').trim()
logInfo('[kuaishou-cloud/binding]', '虚拟号申请成功', {
sourceKey,
@@ -115,51 +103,51 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb
purpose: String(input.purpose || 'prepare_binding'),
vnKey,
vnId,
});
})
if (!vnId || !vnPhone) {
throw createHttpError("申请虚拟号成功但返回数据不完整", {
throw createHttpError('申请虚拟号成功但返回数据不完整', {
statusCode: 502,
errorCode: "kuaishou_cloud_invalid_vn",
});
errorCode: 'kuaishou_cloud_invalid_vn',
})
}
await generateCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
});
})
const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({
...cloudContext,
key: vnKey,
phone: vnPhone,
});
})
await verifyCloudtentaclesLoginCode({
...cloudContext,
key: vnKey,
id: vnId,
code: fetchedCode.code,
});
})
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: vnKey,
id: vnId,
});
})
return {
vnKey,
vnId,
vnPhone,
bindUrl: String(bindUrlResult.bindUrl || "").trim(),
};
bindUrl: String(bindUrlResult.bindUrl || '').trim(),
}
} catch (error) {
lastError = error;
lastError = error
if (isAppointQuotaExhaustedError(error)) {
appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS);
appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS)
}
if (vnId > 0) {
@@ -168,112 +156,108 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb
...cloudContext,
key: vnKey,
id: vnId,
});
})
} catch {
// 退号失败保留主错误
}
}
if (!isRecoverableKuaishouCloudVnKeyError(error)) {
throw error;
throw error
}
}
}
throw (
lastError ||
createHttpError("没有找到可用的 VN Key", {
createHttpError('没有找到可用的 VN Key', {
statusCode: 409,
errorCode: "kuaishou_cloud_missing_binding_config",
errorCode: 'kuaishou_cloud_missing_binding_config',
})
);
)
}
function collectKuaishouCloudNameCandidates(flow: JsonObject) {
return Array.from(
new Set(
[
String(flow?.binding?.skuName || "").trim(),
String(flow?.internalSkuName || "").trim(),
String(flow?.internalSkuCode || "").trim(),
].filter(Boolean)
)
);
String(flow?.binding?.skuName || '').trim(),
String(flow?.internalSkuName || '').trim(),
String(flow?.internalSkuCode || '').trim(),
].filter(Boolean),
),
)
}
function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) {
const normalizedItems = Array.isArray(items) ? items : [];
const normalizedItems = Array.isArray(items) ? items : []
const normalizedNames = nameCandidates
.map((item: string) => ({
raw: String(item || "").trim(),
raw: String(item || '').trim(),
normalized: normalizeProductName(item),
}))
.filter((item) => item.raw && item.normalized);
.filter((item) => item.raw && item.normalized)
if (normalizedNames.length === 0 || normalizedItems.length === 0) {
return preferredId > 0
? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) ||
null
: null;
? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null
: null
}
if (preferredId > 0) {
const preferred =
normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) ||
null;
normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null
if (preferred) {
return preferred;
return preferred
}
}
const exactMatches = normalizedItems.filter((item: JsonObject) => {
const itemName = normalizeProductName(item.name);
const itemName = normalizeProductName(item.name)
return normalizedNames.some(
(candidate: { normalized: string }) => candidate.normalized === itemName
);
});
(candidate: { normalized: string }) => candidate.normalized === itemName,
)
})
if (exactMatches.length > 0) {
return exactMatches[0];
return exactMatches[0]
}
const partialMatches = normalizedItems.filter((item: JsonObject) => {
const itemName = normalizeProductName(item.name);
const itemName = normalizeProductName(item.name)
return normalizedNames.some(
(candidate: { normalized: string }) =>
itemName.includes(candidate.normalized) ||
candidate.normalized.includes(itemName)
);
});
itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName),
)
})
if (partialMatches.length > 0) {
return partialMatches.sort(
(left, right) =>
String(left.name || "").length - String(right.name || "").length
)[0];
(left, right) => String(left.name || '').length - String(right.name || '').length,
)[0]
}
return null;
return null
}
function isCloudSkuLikeItem(item: unknown): item is JsonObject {
const current = item && typeof item === "object" ? item as JsonObject : {};
return Number(current.id || 0) > 0;
const current = item && typeof item === 'object' ? (item as JsonObject) : {}
return Number(current.id || 0) > 0
}
function isAppointQuotaExhaustedError(error: unknown) {
const current = error && typeof error === "object" ? error as JsonObject : {};
const current = error && typeof error === 'object' ? (error as JsonObject) : {}
return (
String(current.errorCode || current.code || "").trim() ===
"cloudtentacles_vn_appoint_failed" &&
String(current.message || "").trim().includes("最多同时占用")
);
String(current.errorCode || current.code || '').trim() === 'cloudtentacles_vn_appoint_failed' &&
String(current.message || '')
.trim()
.includes('最多同时占用')
)
}
function isRecoverableKuaishouCloudVnKeyError(error: unknown) {
const current = error && typeof error === "object" ? error as JsonObject : {};
const errorCode = String(current.errorCode || current.code || "").trim();
const errorMessage = String(current.message || "").trim();
const current = error && typeof error === 'object' ? (error as JsonObject) : {}
const errorCode = String(current.errorCode || current.code || '').trim()
const errorMessage = String(current.message || '').trim()
return (
errorCode === "cloudtentacles_vn_bind_url_failed" &&
errorMessage.includes("不支持的游戏类型")
);
errorCode === 'cloudtentacles_vn_bind_url_failed' && errorMessage.includes('不支持的游戏类型')
)
}
@@ -1,11 +1,11 @@
import { createHttpError } from "../../../utils/http.js";
import { createHttpError } from '../../../utils/http.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from "../../platforms/cloudtentacles/defaults.js";
import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js";
import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js";
import { normalizeStringArray } from "./domain.js";
} from '../../platforms/cloudtentacles/defaults.js'
import { getCloudtentaclesSourceByKey } from '../../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionStateByKey } from '../../platforms/cloudtentacles/session-state-service.js'
import { normalizeStringArray } from './domain.js'
/**
* 严格模式:只解析列表中第一个账号(调用方均把任务实际取号账号 resolvedSourceKey 放首位),
@@ -14,21 +14,19 @@ import { normalizeStringArray } from "./domain.js";
* 退号、取链、发货等「操作任务已有号码」的场景必须使用此函数。
* 将实际使用的 resolvedSourceKey 也返回,确保后续操作使用同一个 sourceKey。
*/
export function resolvePersistedCloudtentaclesContextBySourceKeys(
sourceKeys: unknown[] = []
) {
const candidates = [...new Set(normalizeStringArray(sourceKeys))];
export function resolvePersistedCloudtentaclesContextBySourceKeys(sourceKeys: unknown[] = []) {
const candidates = [...new Set(normalizeStringArray(sourceKeys))]
if (candidates.length === 0) {
throw createHttpError(
"cloudtentacles 没有可用账号,请先到平台配置完成账号配置",
{ statusCode: 409, errorCode: "kuaishou_cloud_no_source_keys" }
);
throw createHttpError('cloudtentacles 没有可用账号,请先到平台配置完成账号配置', {
statusCode: 409,
errorCode: 'kuaishou_cloud_no_source_keys',
})
}
const sourceKey = String(candidates[0] || "").trim();
const sourceKey = String(candidates[0] || '').trim()
return resolveSingleCloudtentaclesAccountContext(sourceKey);
return resolveSingleCloudtentaclesAccountContext(sourceKey)
}
/**
@@ -36,77 +34,69 @@ export function resolvePersistedCloudtentaclesContextBySourceKeys(
* 仅用于「取新号」场景(新号码归属被选中的账号,不会产生跨账号孤儿),
* 以及 account-selector 单候选解析。严禁用于退号/取链/发货等操作已有号码的场景。
*/
export function resolvePersistedCloudtentaclesContextWithFallback(
sourceKeys: unknown[] = []
) {
const candidates = [...new Set(normalizeStringArray(sourceKeys))];
export function resolvePersistedCloudtentaclesContextWithFallback(sourceKeys: unknown[] = []) {
const candidates = [...new Set(normalizeStringArray(sourceKeys))]
let lastError: unknown = null;
let lastError: unknown = null
for (const sourceKey of candidates) {
try {
return resolveSingleCloudtentaclesAccountContext(sourceKey);
return resolveSingleCloudtentaclesAccountContext(sourceKey)
} catch (error) {
lastError = error;
lastError = error
}
}
throw (
lastError ||
createHttpError(
"所有 cloudtentacles 账号均不可用,请先到平台配置完成登录校验",
{ statusCode: 409, errorCode: "kuaishou_cloud_all_source_keys_exhausted" }
)
);
createHttpError('所有 cloudtentacles 账号均不可用,请先到平台配置完成登录校验', {
statusCode: 409,
errorCode: 'kuaishou_cloud_all_source_keys_exhausted',
})
)
}
function resolveSingleCloudtentaclesAccountContext(sourceKey: string) {
const source = getCloudtentaclesSourceByKey(sourceKey);
const session = getCloudtentaclesSessionStateByKey(sourceKey);
const source = getCloudtentaclesSourceByKey(sourceKey)
const session = getCloudtentaclesSessionStateByKey(sourceKey)
if (!source) {
throw createHttpError(
`cloudtentacles 账号 ${sourceKey} 不存在(取号账号已变更或被删除,无法继续操作其虚拟号)`,
{ statusCode: 409, errorCode: "kuaishou_cloud_source_missing" }
);
{ statusCode: 409, errorCode: 'kuaishou_cloud_source_missing' },
)
}
if (source.enabled === false) {
throw createHttpError(
`cloudtentacles 账号 ${sourceKey} 已停用`,
{ statusCode: 409, errorCode: "kuaishou_cloud_source_disabled" }
);
throw createHttpError(`cloudtentacles 账号 ${sourceKey} 已停用`, {
statusCode: 409,
errorCode: 'kuaishou_cloud_source_disabled',
})
}
if (!session) {
throw createHttpError(
`cloudtentacles 账号 ${sourceKey} 没有可用 token`,
{ statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" }
);
throw createHttpError(`cloudtentacles 账号 ${sourceKey} 没有可用 token`, {
statusCode: 409,
errorCode: 'kuaishou_cloud_missing_cloud_token',
})
}
const token = String(session.token || "").trim();
const token = String(session.token || '').trim()
if (!token) {
throw createHttpError(
`cloudtentacles 账号 ${sourceKey} 没有可用 token`,
{ statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" }
);
throw createHttpError(`cloudtentacles 账号 ${sourceKey} 没有可用 token`, {
statusCode: 409,
errorCode: 'kuaishou_cloud_missing_cloud_token',
})
}
return {
baseUrl:
String(session.baseUrl || source.baseUrl || "").trim() ||
"https://123.207.217.176",
baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176',
token,
deviceId: normalizeCloudtentaclesDeviceId(
session.deviceId || source.deviceId
),
deviceType: normalizeCloudtentaclesDeviceType(
session.deviceType ?? source.deviceType
),
deviceId: normalizeCloudtentaclesDeviceId(session.deviceId || source.deviceId),
deviceType: normalizeCloudtentaclesDeviceType(session.deviceType ?? source.deviceType),
// 透传给 http-client / 错误包装,便于定位跨账号问题
sourceKey,
resolvedSourceKey: sourceKey,
accountLabel: String(source.label || sourceKey).trim() || sourceKey,
};
}
}
@@ -47,7 +47,8 @@ export async function confirmKuaishouCloudTaskRole(
}
const expectedUid = assertClaimExpectedUidReady(task)
const mockMode = isKuaishouCloudMockTask(task) || isKuaishouCloudMockContext(parseTaskContext(task))
const mockMode =
isKuaishouCloudMockTask(task) || isKuaishouCloudMockContext(parseTaskContext(task))
const refreshed = mockMode
? { task }
@@ -58,9 +59,7 @@ export async function confirmKuaishouCloudTaskRole(
forceProbe: options.forceProbe !== false,
})
const flow = normalizeKuaishouCloudFlow(
parseTaskContext(refreshed.task).kuaishouCloudFulfillment,
)
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) {
throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', {
@@ -68,8 +68,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
const industryVoucherCode = String(
voucherContext.voucherCode || voucherContext.eticketId || '',
).trim()
const hasIndustryVoucherForDispatch =
Boolean(industryVoucherCode) || isIndustryEVoucherTask(task)
const hasIndustryVoucherForDispatch = Boolean(industryVoucherCode) || isIndustryEVoucherTask(task)
const resolvedTicketCode = persistedTicketCode || industryVoucherCode
if (!resolvedTicketCode && !hasIndustryVoucherForDispatch) {
@@ -190,9 +189,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
purchaseTriggered: stockResult.purchaseTriggered,
assetBefore: stockResult.assetBefore,
assetAfter: stockResult.assetAfter,
purchaseAt: stockResult.purchaseTriggered
? now
: syncedFlow.purchase.purchaseAt,
purchaseAt: stockResult.purchaseTriggered ? now : syncedFlow.purchase.purchaseAt,
items: stockResult.items,
},
},
@@ -255,9 +252,7 @@ export async function dispatchKuaishouCloudFulfillmentTask(
return {
task: updatedTask,
flow: normalizeKuaishouCloudFlow(
parseTaskContext(updatedTask).kuaishouCloudFulfillment,
),
flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment),
}
}
@@ -1,75 +1,65 @@
import { createTaskEvent } from "../../../repositories/task-event-repo.js";
import { updateTask } from "../../../repositories/task-repo.js";
import type { TaskRow } from "../../../types/repository/rows.js";
import { createHttpError } from "../../../utils/http.js";
import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js";
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import { createHttpError } from '../../../utils/http.js'
import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js'
import {
assertLewanAutoFulfillmentUidReady,
isClaimUidMatched,
normalizeClaimUid,
} from "../../claim/claim-identity.js";
import { asJsonObject } from "../../../types/json.js";
} from '../../claim/claim-identity.js'
import { asJsonObject } from '../../../types/json.js'
import {
normalizeKuaishouCloudFlow,
normalizeKuaishouCloudRoleInfo,
type JsonObject,
} from "./domain.js";
} from './domain.js'
export async function syncKuaishouCloudRoleInfoBeforeDispatch(
task: TaskRow,
input: JsonObject = {}
input: JsonObject = {},
) {
const now = String(input.now || "").trim() || new Date().toISOString();
const source =
String(input.source || "system_before_dispatch").trim() ||
"system_before_dispatch";
const now = String(input.now || '').trim() || new Date().toISOString()
const source = String(input.source || 'system_before_dispatch').trim() || 'system_before_dispatch'
const errorCodePrefix =
String(input.errorCodePrefix || "kuaishou_cloud").trim() ||
"kuaishou_cloud";
const cloudContext =
asJsonObject(input.cloudContext);
const taskContext =
asJsonObject(input.taskContext);
const flow = normalizeKuaishouCloudFlow(
input.flow || taskContext.kuaishouCloudFulfillment
);
String(input.errorCodePrefix || 'kuaishou_cloud').trim() || 'kuaishou_cloud'
const cloudContext = asJsonObject(input.cloudContext)
const taskContext = asJsonObject(input.taskContext)
const flow = normalizeKuaishouCloudFlow(input.flow || taskContext.kuaishouCloudFulfillment)
// 新策略:lewan 自动发货必须有 expectedUid,旧单无 UID 不可静默回落
const expectedUid = assertLewanAutoFulfillmentUidReady(task, {
allowMockSkip: true,
errorCodePrefix,
});
})
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", {
throw createHttpError('当前任务缺少可同步角色的虚拟号信息,暂时不能发货', {
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_missing_bind_info_context`,
});
})
}
const bindInfoResult = await getCloudtentaclesBindInfo({
...cloudContext,
key: flow.binding.vnKey,
id: flow.binding.vnId,
});
const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo);
})
const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo)
if (!roleInfo.name || !roleInfo.rid) {
throw createHttpError(
"cloudtentacles 还没有同步到客户绑定角色,请稍后刷新角色信息后再发货",
{
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_role_not_ready`,
}
);
throw createHttpError('cloudtentacles 还没有同步到客户绑定角色,请稍后刷新角色信息后再发货', {
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_role_not_ready`,
})
}
const liveBoundUid = normalizeClaimUid(roleInfo.rid);
const liveBoundUid = normalizeClaimUid(roleInfo.rid)
if (expectedUid && !isClaimUidMatched(expectedUid, liveBoundUid)) {
await createTaskEvent(
task.id,
"kuaishou_cloud_dispatch_uid_mismatch",
'kuaishou_cloud_dispatch_uid_mismatch',
{
source,
vnId: flow.binding.vnId,
@@ -78,16 +68,16 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
cloudtentaclesRoleId: roleInfo.rid,
actor: input.actor || null,
},
now
);
now,
)
throw createHttpError(
`cloudtentacles 当前绑定角色 ID${roleInfo.rid})与用户填写 UID${expectedUid})不一致,不能发货`,
{
statusCode: 409,
errorCode: `${errorCodePrefix}_dispatch_uid_mismatch`,
}
);
},
)
}
const nextFlow = normalizeKuaishouCloudFlow({
@@ -99,36 +89,36 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
},
role: {
...flow.role,
status: "ready",
status: 'ready',
name: roleInfo.name,
rid: roleInfo.rid,
refreshedAt: now,
errorMessage: "",
errorMessage: '',
rawInfo: roleInfo.rawInfo,
},
});
})
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: nextFlow,
};
}
const updatedTask = await updateTask(task.id, {
role_id: roleInfo.rid,
role_name: roleInfo.name,
context_json: JSON.stringify(nextContext),
updated_at: now,
});
})
if (!updatedTask) {
throw createHttpError("发货前角色信息同步失败", {
throw createHttpError('发货前角色信息同步失败', {
statusCode: 500,
errorCode: `${errorCodePrefix}_dispatch_role_update_failed`,
});
})
}
await createTaskEvent(
task.id,
"kuaishou_cloud_role_info_synced_before_dispatch",
'kuaishou_cloud_role_info_synced_before_dispatch',
{
source,
roleName: roleInfo.name,
@@ -136,15 +126,13 @@ export async function syncKuaishouCloudRoleInfoBeforeDispatch(
vnId: flow.binding.vnId,
actor: input.actor || null,
},
now
);
now,
)
return {
task: updatedTask,
taskContext: nextContext,
flow: nextFlow,
roleInfo,
};
}
}
@@ -51,9 +51,8 @@ export function resolveDispatchDeliveryItems(flow: JsonObject): DispatchDelivery
return items
}
const binding = flow.binding && typeof flow.binding === 'object'
? (flow.binding as JsonObject)
: {}
const binding =
flow.binding && typeof flow.binding === 'object' ? (flow.binding as JsonObject) : {}
const fallbackSkuId = Number(binding.skuId || 0) || 0
if (!fallbackSkuId) {
return []
@@ -1,10 +1,6 @@
import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js'
import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js'
import {
isTaskFinalStatus,
normalizeTaskStatus,
TASK_STATUS,
} from '../../../domain/task-status.js'
import { isTaskFinalStatus, normalizeTaskStatus, TASK_STATUS } from '../../../domain/task-status.js'
import { asJsonObject, type JsonObject } from '../../../types/json.js'
export type { JsonObject }
@@ -138,8 +134,7 @@ export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow {
const role = asJsonObject(source.role)
const purchase = asJsonObject(source.purchase)
const dispatch = asJsonObject(source.dispatch)
const returnNumber =
asJsonObject(source.returnNumber)
const returnNumber = asJsonObject(source.returnNumber)
const consume = asJsonObject(source.consume)
const ticket = asJsonObject(source.ticket)
const rebind = asJsonObject(source.rebind)
@@ -280,7 +275,8 @@ export function normalizeKuaishouCloudShippedSnapshot(
roleName: String(source.roleName || source.name || '').trim(),
vnId,
vnPhone: String(source.vnPhone || '').trim(),
vnKey: String(source.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
vnKey:
String(source.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
dispatchedAt,
}
}
@@ -331,8 +327,7 @@ export function buildKuaishouCloudShippedSnapshot(input: {
roleName: String(input.roleName || '').trim(),
vnId: Number(input.vnId || 0) || 0,
vnPhone: String(input.vnPhone || '').trim(),
vnKey:
String(input.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
vnKey: String(input.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY,
dispatchedAt: input.dispatchedAt ? String(input.dispatchedAt) : null,
}
}
@@ -33,11 +33,7 @@ import {
} from './account-selector.js'
import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js'
import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js'
import {
getTaskClaimExpiresAt,
normalizeActor,
parseTaskContext,
} from './task-context.js'
import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js'
import { resolveKuaishouCloudDeliveryPlan } from './delivery-plan.js'
import { ensureTaskClaimLink } from './ensure-claim-link.js'
import {
@@ -332,11 +328,14 @@ export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options
}
function isRetryableCloudtentaclesSourceError(error: unknown) {
const current = error && typeof error === 'object' ? error as JsonObject : {}
const current = error && typeof error === 'object' ? (error as JsonObject) : {}
const code = String(current.errorCode || current.code || '').trim()
return (
code === 'cloudtentacles_vn_quota_cooldown' ||
(code === 'cloudtentacles_vn_appoint_failed' && String(current.message || '').trim().includes('最多同时占用')) ||
(code === 'cloudtentacles_vn_appoint_failed' &&
String(current.message || '')
.trim()
.includes('最多同时占用')) ||
code === 'cloudtentacles_vn_list_failed' ||
code === 'cloudtentacles_sku_list_failed' ||
code === 'cloudtentacles_knapsack_failed' ||
@@ -484,8 +483,9 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
binding: {
...flow.binding,
prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
resolvedSourceKey: String(
cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '',
).trim(),
bindUrl,
bindPreparedAt: now,
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
@@ -667,8 +667,9 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
binding: {
...flow.binding,
prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
resolvedSourceKey: String(
cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '',
).trim(),
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone,
@@ -739,7 +740,6 @@ export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: Js
}
}
async function markKuaishouCloudBindUrlRefreshFailed(
task: TaskRow,
{
@@ -855,7 +855,7 @@ async function clearKuaishouCloudStaleBinding(
error: unknown
claimUrl: string
token: string
}
},
) {
const errorMessage = error instanceof Error ? error.message : String(error || '绑定已失效')
const oldVnId = flow.binding.vnId
@@ -912,7 +912,7 @@ async function clearKuaishouCloudStaleBinding(
errorMessage,
actor,
},
now
now,
)
return {
@@ -51,9 +51,7 @@ export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: Json
const probeIntervalMs =
Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) * 1000
const lastProbeAt = flow.binding.bindProbeAt
? Date.parse(String(flow.binding.bindProbeAt))
: NaN
const lastProbeAt = flow.binding.bindProbeAt ? Date.parse(String(flow.binding.bindProbeAt)) : NaN
if (
!options.force &&
Number.isFinite(lastProbeAt) &&
@@ -187,4 +185,3 @@ async function markKuaishouCloudBindUrlRefreshFailed(
return updatedTask
}
@@ -24,11 +24,7 @@ import {
isCloudtentaclesPermissionLikeError,
wrapCloudtentaclesOperationError,
} from './cloudtentacles-errors.js'
import {
getTaskClaimExpiresAt,
normalizeActor,
parseTaskContext,
} from './task-context.js'
import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js'
import { ensureTaskClaimLink } from './ensure-claim-link.js'
import {
buildPendingRoleWithDefaultSnapshot,
@@ -115,7 +111,8 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
bindExpiresAt: flow.binding.bindExpiresAt,
roleName: flow.binding.roleName || flow.role.name || task.role_name || '',
roleId: flow.binding.roleId || flow.role.rid || task.role_id || '',
resolvedSourceKey: flow.binding.resolvedSourceKey || String(cloudContext.resolvedSourceKey || ''),
resolvedSourceKey:
flow.binding.resolvedSourceKey || String(cloudContext.resolvedSourceKey || ''),
}
const previousRebind: JsonObject =
flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {}
@@ -339,8 +336,9 @@ export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonOb
binding: {
...flow.binding,
prepareStatus: 'ready',
resolvedSourceKey:
String(cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '').trim(),
resolvedSourceKey: String(
cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '',
).trim(),
vnKey: preparedBinding.vnKey,
vnId: preparedBinding.vnId,
vnPhone: preparedBinding.vnPhone,
@@ -6,7 +6,10 @@ import {
canRedeemKuaishouCloudClaimStatus,
isKuaishouCloudRedeemSettledStatus,
} from '../../../domain/task-status.js'
import { assertBoundUidMatchesExpected, assertClaimExpectedUidReady } from '../../claim/claim-identity.js'
import {
assertBoundUidMatchesExpected,
assertClaimExpectedUidReady,
} from '../../claim/claim-identity.js'
/**
* redeem 用例前置条件单测(不打 DB / CT)。
@@ -73,10 +76,7 @@ test('redeem 前置:uid 不一致拒绝', () => {
})
test('redeem 闸门函数与 claim-identity 一致', () => {
assert.throws(
() => assertClaimExpectedUidReady({ context_json: '{}' }),
/填写游戏 UID/,
)
assert.throws(() => assertClaimExpectedUidReady({ context_json: '{}' }), /填写游戏 UID/)
assert.throws(
() =>
assertBoundUidMatchesExpected(
@@ -20,10 +20,7 @@ import {
} from '../../claim/claim-identity.js'
import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, type JsonObject } from './domain.js'
import { dispatchKuaishouCloudFulfillmentTask } from './dispatch-fulfillment.js'
import {
completeMockKuaishouCloudTask,
isKuaishouCloudMockTask,
} from './mock-helpers.js'
import { completeMockKuaishouCloudTask, isKuaishouCloudMockTask } from './mock-helpers.js'
import { normalizeActor, parseTaskContext } from './task-context.js'
export type RedeemKuaishouCloudTaskResult = {
@@ -74,9 +71,7 @@ export async function redeemKuaishouCloudTask(
// WAITING_BINDING + UID 匹配:自动升为 ROLE_CONFIRMED,实现一键兑换
if (currentStatus === TASK_STATUS.WAITING_BINDING) {
const flow = normalizeKuaishouCloudFlow(
parseTaskContext(workingTask).kuaishouCloudFulfillment,
)
const flow = normalizeKuaishouCloudFlow(parseTaskContext(workingTask).kuaishouCloudFulfillment)
if (!isKuaishouCloudMockTask(workingTask)) {
assertBoundUidMatchesExpected(workingTask, flow, {
errorCodePrefix,
@@ -122,16 +117,12 @@ export async function redeemKuaishouCloudTask(
assertClaimExpectedUidReady(workingTask)
}
const lockedTask = await updateTaskStatusIfCurrent(
workingTask.id,
TASK_STATUS.ROLE_CONFIRMED,
{
task_status: TASK_STATUS.REDEEMING,
user_action_status: 'not_required',
last_error: '',
updated_at: now,
},
)
const lockedTask = await updateTaskStatusIfCurrent(workingTask.id, TASK_STATUS.ROLE_CONFIRMED, {
task_status: TASK_STATUS.REDEEMING,
user_action_status: 'not_required',
last_error: '',
updated_at: now,
})
if (!lockedTask) {
const latest = (await getTaskById(workingTask.id)) || workingTask
@@ -174,8 +165,7 @@ export async function redeemKuaishouCloudTask(
throw error
}
const message =
error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理'
const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理'
await updateTask(lockedTask.id, {
task_status: TASK_STATUS.MANUAL_REVIEW,
user_action_status: 'not_required',
@@ -23,10 +23,7 @@ import { normalizeActor, parseTaskContext } from './task-context.js'
* - 默认(发货后 autoFinalize):退号 + 尝试行业电子凭证核销收尾
* - `consumeIndustryVoucher: false`admin 清理):只退虚拟号,避免占号;与核销无关
*/
export async function returnKuaishouCloudFulfillmentTask(
task: TaskRow,
options: JsonObject = {},
) {
export async function returnKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', {
statusCode: 409,
@@ -79,7 +76,7 @@ export async function returnKuaishouCloudFulfillmentTask(
errorMessage: error instanceof Error ? error.message : String(error || ''),
actor,
},
now
now,
)
} else {
throw error
@@ -113,8 +110,7 @@ export async function returnKuaishouCloudFulfillmentTask(
} else if (shouldConsumeIndustryVoucher) {
const industryResult = hasIndustryVoucher
? await consumeKuaishouIndustryVouchersForTask(task, {
source:
String(options.source || 'system_auto_finalize').trim() || 'system_auto_finalize',
source: String(options.source || 'system_auto_finalize').trim() || 'system_auto_finalize',
token: String(
isPlainObject(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher.token || ''
@@ -122,7 +118,11 @@ export async function returnKuaishouCloudFulfillmentTask(
).trim(),
consumeTime: Date.now(),
})
: { ok: true, consumed: [] as Array<Record<string, unknown>>, failed: [] as Array<{ errorMessage?: string }> }
: {
ok: true,
consumed: [] as Array<Record<string, unknown>>,
failed: [] as Array<{ errorMessage?: string }>,
}
if (industryResult.ok) {
consumeStatus = 'success'
@@ -158,8 +158,7 @@ export async function returnKuaishouCloudFulfillmentTask(
if (shouldConsumeIndustryVoucher) {
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
nextResultCode = 'kuaishou_cloud_consume_failed'
nextResultMessage =
consumeErrorMessage || '号码已退还,但电子凭证核销未完成,请人工处理'
nextResultMessage = consumeErrorMessage || '号码已退还,但电子凭证核销未完成,请人工处理'
} else {
nextResultCode = allowConsumeIndustryVoucher
? 'kuaishou_cloud_completed_without_eticket_consume'
@@ -256,12 +255,15 @@ export async function returnKuaishouCloudFulfillmentTask(
}
}
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { const voucher = isPlainObject(value.kuaishouIndustryVoucher)
? value.kuaishouIndustryVoucher
: {}
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
const voucher = isPlainObject(value.kuaishouIndustryVoucher) ? value.kuaishouIndustryVoucher : {}
const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim()
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
const sendCallbackStatus = String(voucher.sendCallbackStatus || 'success').trim().toLowerCase()
const status = String(voucher.status || 'UNUSED')
.trim()
.toUpperCase()
const sendCallbackStatus = String(voucher.sendCallbackStatus || 'success')
.trim()
.toLowerCase()
return Boolean(voucherCode && status !== 'DESTROYED' && sendCallbackStatus === 'success')
}
@@ -1,20 +1,20 @@
import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js";
import type { TaskRow } from "../../../types/repository/rows.js";
import { parseTaskContext as parseTaskContextValue } from '../../../utils/task-json.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import type { JsonObject } from '../../../types/json.js'
export function normalizeActor(actor: unknown) {
if (!actor || typeof actor !== "object") {
return null;
if (!actor || typeof actor !== 'object') {
return null
}
const current = actor as JsonObject;
const source = String(current.source || "").trim();
const userId = Number(current.userId || 0) || 0;
const username = String(current.username || "").trim();
const role = String(current.role || "").trim();
const current = actor as JsonObject
const source = String(current.source || '').trim()
const userId = Number(current.userId || 0) || 0
const username = String(current.username || '').trim()
const role = String(current.role || '').trim()
if (!source && !userId && !username && !role) {
return null;
return null
}
return {
@@ -22,22 +22,24 @@ export function normalizeActor(actor: unknown) {
userId,
username,
role,
};
}
}
export function parseTaskContext(task: Partial<TaskRow> | null | undefined) {
return parseTaskContextValue(task);
return parseTaskContextValue(task)
}
export function getTaskClaimExpiresAt(task: Partial<TaskRow> | null | undefined) {
return task?.claim_expires_at || task?.primary_claim_expires_at || null;
return task?.claim_expires_at || task?.primary_claim_expires_at || null
}
export function isClaimExpired(expiredAt: unknown) {
if (!expiredAt) {
return false;
return false
}
const timestamp = new Date(expiredAt instanceof Date ? expiredAt : String(expiredAt || "")).getTime();
return Number.isFinite(timestamp) && timestamp <= Date.now();
const timestamp = new Date(
expiredAt instanceof Date ? expiredAt : String(expiredAt || ''),
).getTime()
return Number.isFinite(timestamp) && timestamp <= Date.now()
}
@@ -1,14 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildDispatchStockItems } from "./task-finalization.js";
import { buildDispatchStockItems } from './task-finalization.js'
test("buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口", () => {
test('buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口', () => {
const result = buildDispatchStockItems(
[
{
cloudSkuId: 101,
cloudSkuName: "",
cloudSkuName: '',
quantity: 3,
},
],
@@ -16,38 +16,38 @@ test("buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口",
skuItems: [
{
id: 101,
name: "套装-暗影哥特",
name: '套装-暗影哥特',
price: 88,
},
],
knapsackItems: [
{
id: 101,
name: "套装-暗影哥特",
name: '套装-暗影哥特',
count: 1,
},
],
}
);
},
)
assert.deepEqual(result, [
{
cloudSkuId: 101,
cloudSkuName: "套装-暗影哥特",
cloudSkuName: '套装-暗影哥特',
quantity: 3,
requiredCount: 3,
knapsackCount: 1,
purchasedCount: 2,
},
]);
});
])
})
test("buildDispatchStockItems 背包足够时不需要补买", () => {
test('buildDispatchStockItems 背包足够时不需要补买', () => {
const result = buildDispatchStockItems(
[
{
cloudSkuId: 202,
cloudSkuName: "套装-浪漫天命",
cloudSkuName: '套装-浪漫天命',
quantity: 1,
},
],
@@ -55,13 +55,13 @@ test("buildDispatchStockItems 背包足够时不需要补买", () => {
knapsackItems: [
{
id: 202,
name: "套装-浪漫天命",
name: '套装-浪漫天命',
count: 5,
},
],
}
);
},
)
assert.equal(result[0]?.knapsackCount, 5);
assert.equal(result[0]?.purchasedCount, 0);
});
assert.equal(result[0]?.knapsackCount, 5)
assert.equal(result[0]?.purchasedCount, 0)
})
@@ -64,43 +64,56 @@ test('resolveKuaishouFeifeiH5UrlWithUid appends uid query', () => {
test('buildFeifeiPlatformOrderNo prefers source order number', () => {
assert.equal(
buildFeifeiPlatformOrderNo(createTask({
platform_order_id: 'KS202607080001',
task_no: 'DT600a8bd23b8',
})),
buildFeifeiPlatformOrderNo(
createTask({
platform_order_id: 'KS202607080001',
task_no: 'DT600a8bd23b8',
}),
),
'KS202607080001',
)
})
test('buildFeifeiPlatformOrderNo falls back to task number', () => {
assert.equal(
buildFeifeiPlatformOrderNo(createTask({
platform_order_id: '',
task_no: 'DT600a8bd23b8',
})),
buildFeifeiPlatformOrderNo(
createTask({
platform_order_id: '',
task_no: 'DT600a8bd23b8',
}),
),
'DT600a8bd23b8',
)
})
test('resolveKuaishouFeifeiPlatformAmount converts order fen amount to yuan', () => {
assert.equal(resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 3800,
quantity: 1,
}), 38)
assert.equal(
resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 3800,
quantity: 1,
}),
38,
)
})
test('resolveKuaishouFeifeiPlatformAmount splits amount by item quantity', () => {
assert.equal(resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 7600,
quantity: 2,
}), 38)
assert.equal(
resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 7600,
quantity: 2,
}),
38,
)
})
test('resolveKuaishouFeifeiPlatformAmount skips empty amount', () => {
assert.equal(resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 0,
quantity: 1,
}), 0)
assert.equal(
resolveKuaishouFeifeiPlatformAmount({
totalAmountFen: 0,
quantity: 1,
}),
0,
)
})
function createTask(patch: Partial<TaskRow> = {}): TaskRow {
@@ -173,19 +173,25 @@ export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
lastError = resultMessage
nextFlow.consumeStatus = 'failed'
logIntegration('[kuaishou-feifei]', '飞飞履约完成,但行业电子凭证核销失败', {
taskId: task.id,
platformOrderNo: nextFlow.platformOrderNo,
orderNo: nextFlow.orderNo,
voucherCount: consumeResult.vouchers.length,
failedCount: consumeResult.failed.length,
errorMessage: resultMessage,
}, { level: 'warn' })
logIntegration(
'[kuaishou-feifei]',
'飞飞履约完成,但行业电子凭证核销失败',
{
taskId: task.id,
platformOrderNo: nextFlow.platformOrderNo,
orderNo: nextFlow.orderNo,
voucherCount: consumeResult.vouchers.length,
failedCount: consumeResult.failed.length,
errorMessage: resultMessage,
},
{ level: 'warn' },
)
}
} else if ([40, 50, 60].includes(order.rechargeStatus)) {
nextTaskStatus = order.rechargeStatus === 60 ? TASK_STATUS.CLOSED : TASK_STATUS.MANUAL_REVIEW
resultCode = `kuaishou_feifei_status_${order.rechargeStatus}`
resultMessage = order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常'
resultMessage =
order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常'
lastError = resultMessage
}
@@ -229,10 +235,9 @@ export type KuaishouFeifeiFlow = {
}
export function normalizeKuaishouFeifeiFlow(value: unknown): KuaishouFeifeiFlow {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
const source =
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
const h5 = source.h5 && typeof source.h5 === 'object' ? (source.h5 as JsonObject) : {}
return {
flowType: 'kuaishou_feifei',
@@ -263,10 +268,7 @@ export function resolveKuaishouFeifeiClaimUrl(value: unknown) {
}
/** 在已有 expectedUid 时返回拼好 uid 的 H5 链接。 */
export function resolveKuaishouFeifeiH5UrlWithUid(
value: unknown,
expectedUid?: unknown,
) {
export function resolveKuaishouFeifeiH5UrlWithUid(value: unknown, expectedUid?: unknown) {
const directUrl = resolveKuaishouFeifeiClaimUrl(value)
if (!directUrl) {
return ''
@@ -319,9 +321,7 @@ function mergeKuaishouFeifeiOrder(
}
}
function resolveKuaishouFeifeiDirectClaimUrl(
flow: KuaishouFeifeiFlow,
) {
function resolveKuaishouFeifeiDirectClaimUrl(flow: KuaishouFeifeiFlow) {
const h5ClaimUrl = flow.h5.rechargeUrl || flow.h5.entryUrl
if (h5ClaimUrl) {
return h5ClaimUrl
@@ -335,7 +335,9 @@ function resolveKuaishouFeifeiDirectClaimUrl(
return isLocalClaimUrl(flow.claimUrl) ? '' : flow.claimUrl
}
function resolveKuaishouFeifeiOrderClaimUrl(order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>) {
function resolveKuaishouFeifeiOrderClaimUrl(
order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>,
) {
return String(order.h5.rechargeUrl || order.h5.entryUrl || '').trim()
}
@@ -62,7 +62,9 @@ test('resolveOrderFulfillmentReadiness 电子凭证发码未确认时阻止履
})
})
function createVoucher(patch: Partial<KuaishouIndustryVoucherRow> = {}): KuaishouIndustryVoucherRow {
function createVoucher(
patch: Partial<KuaishouIndustryVoucherRow> = {},
): KuaishouIndustryVoucherRow {
return {
id: 1,
voucher_code: 'ETICKET-1',
@@ -39,7 +39,9 @@ export async function resolveOrderFulfillmentReadiness(
}
}
const blockedVoucher = vouchers.find((voucher) => !isKuaishouIndustryVoucherSendCallbackSuccess(voucher))
const blockedVoucher = vouchers.find(
(voucher) => !isKuaishouIndustryVoucherSendCallbackSuccess(voucher),
)
return {
allowed: false,
@@ -54,9 +54,9 @@ export async function planFulfillmentTaskForOrderItem({
const profileId = Number(profile.profile_id || profile.id || 0)
const profileKey = String(profile.profile_key || '').trim()
const profileName = String(profile.profile_name || profile.name || '').trim()
const executorKey = String(profile.executor_key || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH)
.trim()
|| FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
const executorKey =
String(profile.executor_key || FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH).trim() ||
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
const fulfillmentConfig = parseJsonObject(profile.config_json)
const context = buildFulfillmentTaskContext({
order,
@@ -229,9 +229,7 @@ function buildFulfillmentTaskContext({
? {
flowType: 'affiliate_dash',
sku: String(affiliateDashConfig.sku || '').trim(),
productName: String(
affiliateDashConfig.productName || item.sku_name || '',
).trim(),
productName: String(affiliateDashConfig.productName || item.sku_name || '').trim(),
orderNo: '',
clientOrderNo: '',
orderStatus: '',
@@ -279,9 +277,9 @@ async function resolveDynamicFulfillmentProfile(
}
return (
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) ||
await resolveDynamicAffiliateDashProfile(item, getProfileByKey)
(await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)) ||
(await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)) ||
(await resolveDynamicAffiliateDashProfile(item, getProfileByKey))
)
}
@@ -8,23 +8,23 @@ import {
} from './product-resolution-service.js'
test('matchAffiliateDashSku 原样精确命中', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
const mapping = { 90: 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '幸运币90个'), 'lucky_coin_x90')
})
test('matchAffiliateDashSku 全角数字/空格归一命中', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
const mapping = { 90: 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '幸运币90个'), 'lucky_coin_x90')
assert.equal(matchAffiliateDashSku(mapping, ' 幸运币 90 个 '), 'lucky_coin_x90')
})
test('matchAffiliateDashSku 大小写归一命中', () => {
const mapping = { '星际漫游服装礼包': 'pack_star_roam_outfit' }
const mapping = { : 'pack_star_roam_outfit' }
assert.equal(matchAffiliateDashSku(mapping, '星际漫游服装礼包'), 'pack_star_roam_outfit')
})
test('matchAffiliateDashSku 未命中返回空串', () => {
const mapping = { '幸运币90个': 'lucky_coin_x90' }
const mapping = { 90: 'lucky_coin_x90' }
assert.equal(matchAffiliateDashSku(mapping, '神秘新商品'), '')
assert.equal(matchAffiliateDashSku(mapping, ''), '')
})
@@ -7,10 +7,7 @@ import {
resolveKuaishouFeifeiProductByName,
type KuaishouFeifeiProductMatch,
} from '../platforms/kuaishou-feifei/product-rule-service.js'
import {
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { resolveFulfillmentRoute, type FulfillmentRouteResult } from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js'
import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js'
@@ -97,9 +94,7 @@ export async function resolveOrderItemForFulfillment({
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.productCode
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '',
externalSkuCode,
externalItemId,
])
@@ -110,9 +105,7 @@ export async function resolveOrderItemForFulfillment({
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.skuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '',
item.skuName,
externalSkuName,
resolvedSkuCode,
@@ -187,11 +180,13 @@ export async function hasConfiguredOrderItems({
items = [],
}: HasConfiguredOrderItemsInput): Promise<boolean> {
const candidates = await Promise.all(
(Array.isArray(items) ? items : []).map((item) => resolveConfiguredItemCandidate({
provider,
platform,
item,
})),
(Array.isArray(items) ? items : []).map((item) =>
resolveConfiguredItemCandidate({
provider,
platform,
item,
}),
),
)
return candidates.some((item) => item.isConfigured)
@@ -217,20 +212,9 @@ async function resolveConfiguredItemCandidate({
platform = '',
item = {},
}: ResolveOrderItemForFulfillmentInput): Promise<FulfillmentItemCandidate> {
const externalItemId = pickFirstNonEmpty([
item.externalItemId,
item.itemId,
])
const externalSkuCode = pickFirstNonEmpty([
item.externalSkuCode,
item.skuCode,
externalItemId,
])
const externalSkuName = pickFirstNonEmpty([
item.externalSkuName,
item.skuName,
externalSkuCode,
])
const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId])
const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId])
const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode])
const externalSkuNameNormalized = normalizeProductName(externalSkuName)
if (isOpen91KuaishouOrder(provider, platform)) {
@@ -293,10 +277,7 @@ async function resolveConfiguredItemCandidate({
}
}
const resolvedSkuCode = pickFirstNonEmpty([
externalSkuCode,
externalItemId,
])
const resolvedSkuCode = pickFirstNonEmpty([externalSkuCode, externalItemId])
return {
externalItemId,
@@ -370,22 +351,32 @@ let affiliateDashSkuCache: { skus: Set<string>; fetchedAt: number } | null = nul
async function getAffiliateDashSkuSet(): Promise<Set<string>> {
const now = Date.now()
if (affiliateDashSkuCache && now - affiliateDashSkuCache.fetchedAt < AFFILIATE_DASH_SKU_CACHE_TTL_MS) {
if (
affiliateDashSkuCache &&
now - affiliateDashSkuCache.fetchedAt < AFFILIATE_DASH_SKU_CACHE_TTL_MS
) {
return affiliateDashSkuCache.skus
}
try {
const result = await listAllAffiliateDashProducts()
const skus = new Set<string>(result.list.map((product) => String(product.sku || '').trim()).filter(Boolean))
const skus = new Set<string>(
result.list.map((product) => String(product.sku || '').trim()).filter(Boolean),
)
affiliateDashSkuCache = { skus, fetchedAt: now }
return skus
} catch (error) {
// 拉取失败:返回上次缓存(即使过期)或空集合,匹配 miss 走其他通道,不阻塞下单;
// warn 日志便于线上排查(密钥未配/平台不可用都会导致透传降级为 miss)。
logIntegration('[affiliate-dash]', 'affiliate-dash 商品列表拉取失败,透传降级为未命中', {
cachedSkuCount: affiliateDashSkuCache?.skus.size || 0,
error: error instanceof Error ? error.message : String(error),
}, { level: 'warn' })
logIntegration(
'[affiliate-dash]',
'affiliate-dash 商品列表拉取失败,透传降级为未命中',
{
cachedSkuCount: affiliateDashSkuCache?.skus.size || 0,
error: error instanceof Error ? error.message : String(error),
},
{ level: 'warn' },
)
return affiliateDashSkuCache?.skus || new Set<string>()
}
}
@@ -394,10 +385,7 @@ async function getAffiliateDashSkuSet(): Promise<Set<string>> {
* 91 商品编码为中文名(如「幸运币90个」),映射键查询做归一化:
* NFKC(全角→半角)→ 去所有空白 → 小写。先原样精确,再归一化遍历。
*/
export function matchAffiliateDashSku(
mapping: AffiliateDashSkuMapping,
productNo: string,
): string {
export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productNo: string): string {
const raw = String(productNo || '').trim()
if (!raw) {
return ''
@@ -425,8 +413,9 @@ export function normalizeAffiliateDashMappingKey(value: unknown): string {
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
return String(provider || '').trim() === '91kaquan' &&
String(platform || '').trim() === 'kuaishou'
return (
String(provider || '').trim() === '91kaquan' && String(platform || '').trim() === 'kuaishou'
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -2,10 +2,7 @@ import path from 'node:path'
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import { PROJECT_ROOT } from '../../config/runtime.js'
import {
readAppConfigEntry,
saveAppConfigEntry,
} from '../config/app-config-store.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
import { normalizeCloudtentaclesMatchName } from '../order/cloudtentacles-match-utils.js'
import type { JsonObject } from '../../types/json.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
@@ -113,11 +110,13 @@ export function normalizeFulfillmentRoutingConfig(rawValue: unknown): Fulfillmen
defaultExecutorPriority:
defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY],
unmatchedExecutorKey:
unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ? unmatchedExecutorKey : '',
unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
? unmatchedExecutorKey
: '',
executors: ROUTABLE_EXECUTOR_KEYS.reduce<Record<string, FulfillmentRoutingExecutorConfig>>(
(result, executorKey) => {
const executorConfig = isPlainObject(sourceExecutors[executorKey])
? sourceExecutors[executorKey] as JsonObject
? (sourceExecutors[executorKey] as JsonObject)
: {}
result[executorKey] = {
enabled: executorConfig.enabled !== false,
@@ -383,17 +382,19 @@ function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingR
return null
}
return rules.find((rule) => {
if (rule.enabled === false || !rule.normalizedProductName) {
return false
}
return (
rules.find((rule) => {
if (rule.enabled === false || !rule.normalizedProductName) {
return false
}
if (rule.matchType === 'contains') {
return normalizedProductName.includes(rule.normalizedProductName)
}
if (rule.matchType === 'contains') {
return normalizedProductName.includes(rule.normalizedProductName)
}
return normalizedProductName === rule.normalizedProductName
}) || null
return normalizedProductName === rule.normalizedProductName
}) || null
)
}
function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingRule | null {
@@ -409,7 +410,8 @@ function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingR
}
return {
id: String(source.id || `${normalizedProductName}:${executorKey}`).trim() ||
id:
String(source.id || `${normalizedProductName}:${executorKey}`).trim() ||
`${normalizedProductName}:${executorKey}`,
enabled: source.enabled !== false,
productName,
@@ -446,7 +448,11 @@ function normalizeExecutorPriority(value: unknown) {
for (const item of rawItems) {
const executorKey = normalizeExecutorKey(item)
if (!executorKey || seen.has(executorKey) || executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
if (
!executorKey ||
seen.has(executorKey) ||
executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH
) {
continue
}
seen.add(executorKey)
@@ -17,7 +17,9 @@ type BarkSendInput = {
}
export async function sendBarkNotification(input: BarkSendInput) {
const serverUrl = String(input.serverUrl || 'https://api.day.app').trim().replace(/\/+$/, '')
const serverUrl = String(input.serverUrl || 'https://api.day.app')
.trim()
.replace(/\/+$/, '')
const deviceKey = String(input.recipient?.deviceKey || '').trim()
const title = String(input.title || '').trim()
const body = String(input.body || '').trim()
@@ -88,11 +90,7 @@ export async function sendBarkNotification(input: BarkSendInput) {
}
function buildBarkEndpoint(serverUrl: string, deviceKey: string, title: string, body: string) {
const segments = [
serverUrl,
encodeURIComponent(deviceKey),
encodeURIComponent(title),
]
const segments = [serverUrl, encodeURIComponent(deviceKey), encodeURIComponent(title)]
if (body) {
segments.push(encodeURIComponent(body))
@@ -124,7 +122,10 @@ function isBarkFailure(parsed: unknown) {
function resolveBarkErrorMessage(parsed: unknown, responseText: string, status: number) {
if (isPlainObject(parsed)) {
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `Bark 通知发送失败,HTTP ${status}`
return (
String(parsed.message || parsed.msg || parsed.error || '').trim() ||
`Bark 通知发送失败,HTTP ${status}`
)
}
return String(responseText || '').trim() || `Bark 通知发送失败,HTTP ${status}`
@@ -1,10 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
createDefaultNotificationConfig,
normalizeNotificationConfig,
} from './config-service.js'
import { createDefaultNotificationConfig, normalizeNotificationConfig } from './config-service.js'
test('normalizeNotificationConfig normalizes bark and wpush recipients', () => {
assert.deepEqual(
@@ -3,10 +3,7 @@ import type { JsonObject } from '../../types/json.js'
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import { PROJECT_ROOT } from '../../config/runtime.js'
import {
readAppConfigEntry,
saveAppConfigEntry,
} from '../config/app-config-store.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notification-config.json')
const DEFAULT_BARK_SERVER_URL = 'https://api.day.app'
@@ -46,8 +43,9 @@ export function listEnabledBarkRecipients(config: JsonObject = getNotificationCo
return []
}
return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : [])
.filter((item: JsonObject) => item.enabled !== false && String(item.deviceKey || '').trim())
return (
Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : []
).filter((item: JsonObject) => item.enabled !== false && String(item.deviceKey || '').trim())
}
export function listEnabledWpushRecipients(config: JsonObject = getNotificationConfig()) {
@@ -55,8 +53,11 @@ export function listEnabledWpushRecipients(config: JsonObject = getNotificationC
return []
}
return (Array.isArray(config.channels?.wpush?.recipients) ? config.channels.wpush.recipients : [])
.filter((item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim())
return (
Array.isArray(config.channels?.wpush?.recipients) ? config.channels.wpush.recipients : []
).filter(
(item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim(),
)
}
export function normalizeNotificationConfig(rawValue: unknown) {
@@ -107,11 +107,9 @@ export function notifyKuaishouCloudAssetNotEnough({
`商品:${String(skuName || binding.skuName || flowRecord.internalSkuName || '').trim() || '-'}`,
].join('\n'),
category: 'kuaishou_cloud_asset_not_enough',
cooldownKey: [
'kuaishou_cloud_asset_not_enough',
taskRecord.id || '',
binding.skuId || '',
].join(':'),
cooldownKey: ['kuaishou_cloud_asset_not_enough', taskRecord.id || '', binding.skuId || ''].join(
':',
),
})
}
@@ -135,9 +133,16 @@ export function notifyCloudtentaclesAuthExpired({
`接口:${String(pathname || '').trim() || '-'}`,
`错误码:${String(errorCode || '').trim() || '-'}`,
`原因:${String(message || '').trim() || '登录态已失效,请到后台重新登录'}`,
].filter(Boolean).join('\n'),
]
.filter(Boolean)
.join('\n'),
category: 'cloudtentacles_auth_expired',
cooldownKey: ['cloudtentacles_auth_expired', normalizedSourceKey || 'default', pathname, errorCode].join(':'),
cooldownKey: [
'cloudtentacles_auth_expired',
normalizedSourceKey || 'default',
pathname,
errorCode,
].join(':'),
cooldownMs: Number(cooldownSeconds || 600) * 1000,
})
}
@@ -161,9 +166,13 @@ export function notifyCloudtentaclesAssetLow({
`当前余额:${Number(asset || 0)}`,
`提醒阈值:${Number(threshold || 0)}`,
'请及时补充 cloudtentacles 余额,避免自动履约失败。',
].filter(Boolean).join('\n'),
]
.filter(Boolean)
.join('\n'),
category: 'cloudtentacles_asset_low',
cooldownKey: ['cloudtentacles_asset_low', normalizedSourceKey || 'default', threshold].join(':'),
cooldownKey: ['cloudtentacles_asset_low', normalizedSourceKey || 'default', threshold].join(
':',
),
cooldownMs: Number(cooldownSeconds || 1800) * 1000,
})
}
@@ -186,7 +195,11 @@ export function notifyOpen91PendingConfig({
`原因:${String(reason || '').trim() || '未命中履约配置'}`,
].join('\n'),
category: 'open91_pending_config',
cooldownKey: ['open91_pending_config', orderNo || orderRecord.platform_order_id || '', productNo].join(':'),
cooldownKey: [
'open91_pending_config',
orderNo || orderRecord.platform_order_id || '',
productNo,
].join(':'),
})
}
@@ -258,9 +271,7 @@ function formatTaskLine(task: unknown) {
}
function toRecord(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
}
function isNotificationCoolingDown(cooldownKey: unknown, cooldownMs = DEFAULT_COOLDOWN_MS) {
@@ -37,59 +37,79 @@ export async function sendInternalNotification(input: NotificationInput = {}) {
}
const results = [
...(await Promise.all(barkRecipients.map(async (recipient: JsonObject) => {
try {
const result = await sendBarkNotification({
recipient,
title,
body,
group: `订单系统/${category}`,
...(bark.serverUrl !== undefined ? { serverUrl: bark.serverUrl } : {}),
...(input.url !== undefined ? { url: input.url } : {}),
})
return mapNotificationResult('bark', recipient, recipient.deviceKey, true, '', result.status, result.response)
} catch (error) {
logWarn('[notification/bark]', 'Bark 内部通知发送失败', {
recipientId: recipient.id,
recipientName: recipient.name,
error,
})
return mapNotificationResult(
'bark',
recipient,
recipient.deviceKey,
false,
error instanceof Error ? error.message : 'Bark 内部通知发送失败',
isPlainObject(error) ? Number(error.statusCode || 0) || 0 : 0,
isPlainObject(error) ? error.context || null : null,
)
}
}))),
...(await Promise.all(wpushRecipients.map(async (recipient: JsonObject) => {
try {
const result = await sendWpushNotification({
recipient,
title,
body,
})
return mapNotificationResult('wpush', recipient, recipient.apiKey, true, '', result.status, result.response)
} catch (error) {
logWarn('[notification/wpush]', 'WPush 内部通知发送失败', {
recipientId: recipient.id,
recipientName: recipient.name,
error,
})
return mapNotificationResult(
'wpush',
recipient,
recipient.apiKey,
false,
error instanceof Error ? error.message : 'WPush 内部通知发送失败',
isPlainObject(error) ? Number(error.statusCode || 0) || 0 : 0,
isPlainObject(error) ? error.context || null : null,
)
}
}))),
...(await Promise.all(
barkRecipients.map(async (recipient: JsonObject) => {
try {
const result = await sendBarkNotification({
recipient,
title,
body,
group: `订单系统/${category}`,
...(bark.serverUrl !== undefined ? { serverUrl: bark.serverUrl } : {}),
...(input.url !== undefined ? { url: input.url } : {}),
})
return mapNotificationResult(
'bark',
recipient,
recipient.deviceKey,
true,
'',
result.status,
result.response,
)
} catch (error) {
logWarn('[notification/bark]', 'Bark 内部通知发送失败', {
recipientId: recipient.id,
recipientName: recipient.name,
error,
})
return mapNotificationResult(
'bark',
recipient,
recipient.deviceKey,
false,
error instanceof Error ? error.message : 'Bark 内部通知发送失败',
isPlainObject(error) ? Number(error.statusCode || 0) || 0 : 0,
isPlainObject(error) ? error.context || null : null,
)
}
}),
)),
...(await Promise.all(
wpushRecipients.map(async (recipient: JsonObject) => {
try {
const result = await sendWpushNotification({
recipient,
title,
body,
})
return mapNotificationResult(
'wpush',
recipient,
recipient.apiKey,
true,
'',
result.status,
result.response,
)
} catch (error) {
logWarn('[notification/wpush]', 'WPush 内部通知发送失败', {
recipientId: recipient.id,
recipientName: recipient.name,
error,
})
return mapNotificationResult(
'wpush',
recipient,
recipient.apiKey,
false,
error instanceof Error ? error.message : 'WPush 内部通知发送失败',
isPlainObject(error) ? Number(error.statusCode || 0) || 0 : 0,
isPlainObject(error) ? error.context || null : null,
)
}
}),
)),
]
return {
@@ -66,11 +66,12 @@ test('sendWpushNotification sends POST json payload', async () => {
test('sendWpushNotification rejects missing api key', async () => {
await assert.rejects(
() => sendWpushNotification({
recipient: {},
title: '测试标题',
body: '测试内容',
}),
() =>
sendWpushNotification({
recipient: {},
title: '测试标题',
body: '测试内容',
}),
(error: any) => {
assert.equal(error?.errorCode, 'wpush_api_key_required')
return true
@@ -97,7 +97,12 @@ export async function sendWpushNotification(input: WpushSendInput) {
async function requestViaNodeHttp(
url: URL,
{ method, headers, body, signal }: {
{
method,
headers,
body,
signal,
}: {
method: string
headers: Record<string, string>
body?: string
@@ -107,25 +112,29 @@ async function requestViaNodeHttp(
const transport = url.protocol === 'https:' ? https : http
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method,
headers,
rejectUnauthorized: false,
}, (response) => {
const chunks: Buffer[] = []
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', () => {
resolve({
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
status: Number(response.statusCode || 0),
bodyText: Buffer.concat(chunks).toString('utf8'),
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
})
})
})
response.on('end', () => {
resolve({
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
status: Number(response.statusCode || 0),
bodyText: Buffer.concat(chunks).toString('utf8'),
})
})
},
)
request.on('error', reject)
@@ -135,11 +144,15 @@ async function requestViaNodeHttp(
error.name = 'AbortError'
request.destroy(error)
} else {
signal.addEventListener('abort', () => {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
}, { once: true })
signal.addEventListener(
'abort',
() => {
const error = new Error('Request aborted')
error.name = 'AbortError'
request.destroy(error)
},
{ once: true },
)
}
}
@@ -179,7 +192,10 @@ function isWpushFailure(parsed: unknown) {
function resolveWpushErrorMessage(parsed: unknown, responseText: string, status: number) {
if (isPlainObject(parsed)) {
return String(parsed.message || parsed.msg || parsed.error || '').trim() || `WPush 通知发送失败,HTTP ${status}`
return (
String(parsed.message || parsed.msg || parsed.error || '').trim() ||
`WPush 通知发送失败,HTTP ${status}`
)
}
return String(responseText || '').trim() || `WPush 通知发送失败,HTTP ${status}`
+2 -1
View File
@@ -23,7 +23,8 @@ export function getOpen91Config(overrides: Partial<Open91RuntimeConfig> = {}) {
shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER,
shopName: String(config.shopName || '91卡券').trim() || '91卡券',
timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600,
cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64',
cardsEncoding:
String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64',
}
}
@@ -4,9 +4,11 @@ import test from 'node:test'
import { resolveIndustryVoucherForTask } from './consume-on-deliver.js'
import type { KuaishouIndustryVoucherRow } from '../../types/repository/rows.js'
function voucher(partial: Partial<KuaishouIndustryVoucherRow> & {
voucher_code: string
}): KuaishouIndustryVoucherRow {
function voucher(
partial: Partial<KuaishouIndustryVoucherRow> & {
voucher_code: string
},
): KuaishouIndustryVoucherRow {
return {
id: partial.id || 1,
oid: partial.oid || 'OID-1',
@@ -43,18 +45,14 @@ test('resolveIndustryVoucherForTask prefers task_id over unit_index', () => {
})
test('resolveIndustryVoucherForTask falls back to unit_index when task_id missing', () => {
const vouchers = [
voucher({ id: 1, voucher_code: 'A', task_id: null, unit_index: 2 }),
]
const vouchers = [voucher({ id: 1, voucher_code: 'A', task_id: null, unit_index: 2 })]
const matched = resolveIndustryVoucherForTask({ id: 99, unit_index: 2 }, vouchers)
assert.equal(matched?.voucher_code, 'A')
})
test('resolveIndustryVoucherForTask returns null when no match', () => {
const vouchers = [
voucher({ id: 1, voucher_code: 'A', task_id: 1, unit_index: 1 }),
]
const vouchers = [voucher({ id: 1, voucher_code: 'A', task_id: 1, unit_index: 1 })]
assert.equal(resolveIndustryVoucherForTask({ id: 2, unit_index: 3 }, vouchers), null)
})
@@ -8,22 +8,14 @@
* 交付后会 best-effort 自动准备 Cloud 绑定资源(取号/绑链),避免
* 「已核销却停在待准备资源」只能靠后台手动点「准备资源」。
*/
import {
attachKuaishouIndustryVoucherToTask,
} from '../platforms/kuaishou-industry/voucher-binding-service.js'
import {
consumeKuaishouIndustryVoucher,
} from '../platforms/kuaishou-industry/voucher-service.js'
import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js'
import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js'
import { prepareFulfillmentBinding } from '../fulfillment/executors/registry.js'
import { normalizeKuaishouCloudFlow } from '../fulfillment/kuaishou-cloud/index.js'
import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js'
import { logIntegration, logWarn } from '../../utils/logger.js'
import { parseTaskContext } from '../../utils/task-json.js'
import type {
KuaishouIndustryVoucherRow,
OrderRow,
TaskRow,
} from '../../types/repository/rows.js'
import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
export const OPEN_91_DELIVER_CONSUME_SOURCE = 'open91_query_deliver'
@@ -69,7 +61,11 @@ export function resolveIndustryVoucherForTask(
}
function isVoucherConsumed(voucher: Pick<KuaishouIndustryVoucherRow, 'status'>): boolean {
return String(voucher.status || '').trim().toUpperCase() === 'CONSUMED'
return (
String(voucher.status || '')
.trim()
.toUpperCase() === 'CONSUMED'
)
}
/**
@@ -83,8 +79,9 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: {
requestId?: string
source?: string
}): Promise<Open91DeliverConsumeResult> {
const source = String(options.source || OPEN_91_DELIVER_CONSUME_SOURCE).trim()
|| OPEN_91_DELIVER_CONSUME_SOURCE
const source =
String(options.source || OPEN_91_DELIVER_CONSUME_SOURCE).trim() ||
OPEN_91_DELIVER_CONSUME_SOURCE
const readySet = new Set(
(Array.isArray(options.readyTaskIds) ? options.readyTaskIds : [])
.map((id) => Number(id))
@@ -213,9 +210,7 @@ async function ensureBindingPreparedAfterDeliver(options: {
return
}
const flow = normalizeKuaishouCloudFlow(
parseTaskContext(options.task).kuaishouCloudFulfillment,
)
const flow = normalizeKuaishouCloudFlow(parseTaskContext(options.task).kuaishouCloudFulfillment)
if (flow.binding.prepareStatus === 'ready' && String(flow.binding.bindUrl || '').trim()) {
return
}
@@ -3,7 +3,7 @@ import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-i
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { resolveTaskDeliveryLink } from '../fulfillment/delivery-link-service.js'
import { logIntegration } from '../../utils/logger.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
import { asJsonObject, type JsonObject } from '../../types/json.js'
import {
OPEN_91_MANUAL_FAILED_STATUS,
OPEN_91_PENDING_CONFIG_STATUS,
@@ -114,10 +114,12 @@ export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '
}
readyTaskIds.push(Number(task.id))
cardItems.push(buildOpen91CardItem({
claimUrl,
expireTime: deliveryLink?.expireTime || '',
}))
cardItems.push(
buildOpen91CardItem({
claimUrl,
expireTime: deliveryLink?.expireTime || '',
}),
)
}
const queryState = resolveOpen91QueryState({
+4 -1
View File
@@ -64,7 +64,10 @@ export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpe
assertOpen91CommonPayload(payload, config)
}
export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) {
export function assertOpen91Timestamp(
timestamp: unknown,
toleranceSeconds = assertOpen91Config().timestampToleranceSeconds,
) {
const nowSeconds = Math.floor(Date.now() / 1000)
if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) {
throw createHttpError('timestamp 已过期', {
@@ -7,11 +7,7 @@ import {
signOpen91Payload,
verifyOpen91Signature,
} from './crypto.js'
import {
buildOpen91CardItem,
formatOpen91ExpireTime,
resolveOpen91QueryState,
} from './response.js'
import { buildOpen91CardItem, formatOpen91ExpireTime, resolveOpen91QueryState } from './response.js'
test('signOpen91Payload signs only request body fields', () => {
const config = {
@@ -77,7 +73,10 @@ test('encryptOpen91Cards matches documented AES ECB base64 sample', () => {
test('resolveOpen91QueryState returns success only when every task is ready', () => {
assert.deepEqual(
resolveOpen91QueryState({
tasks: [{ id: 1, task_status: 'pending_binding_prepare' }, { id: 2, task_status: 'waiting_binding' }],
tasks: [
{ id: 1, task_status: 'pending_binding_prepare' },
{ id: 2, task_status: 'waiting_binding' },
],
readyTaskIds: [1, 2],
}),
{
@@ -89,7 +88,10 @@ test('resolveOpen91QueryState returns success only when every task is ready', ()
assert.deepEqual(
resolveOpen91QueryState({
tasks: [{ id: 1, task_status: 'pending_binding_prepare' }, { id: 2, task_status: 'waiting_binding' }],
tasks: [
{ id: 1, task_status: 'pending_binding_prepare' },
{ id: 2, task_status: 'waiting_binding' },
],
readyTaskIds: [1],
}),
{
+12 -9
View File
@@ -1,8 +1,5 @@
import type { JsonObject } from '../../types/json.js'
import {
OPEN_91_DEFAULT_FAIL_CODE,
OPEN_91_SUCCESS_MESSAGE,
} from './config.js'
import { OPEN_91_DEFAULT_FAIL_CODE, OPEN_91_SUCCESS_MESSAGE } from './config.js'
import { isOpen91FailedTaskStatus } from '../../domain/task-status.js'
import { normalizeOpen91String } from './payload.js'
@@ -32,7 +29,10 @@ export function buildOpen91OrderCost(value = 0) {
return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000'
}
export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) {
export function buildOpen91CardItem({
claimUrl = '',
expireTime = '',
}: { claimUrl?: unknown; expireTime?: unknown } = {}) {
const normalizedClaimUrl = normalizeOpen91String(claimUrl)
return {
cardNo: normalizedClaimUrl,
@@ -72,11 +72,14 @@ export function formatOpen91ExpireTime(value: unknown = '') {
return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}`
}
export function resolveOpen91QueryState(
{ tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {},
) {
export function resolveOpen91QueryState({
tasks = [],
readyTaskIds = [],
}: { tasks?: JsonObject[]; readyTaskIds?: unknown[] } = {}) {
const normalizedTasks = Array.isArray(tasks) ? tasks : []
const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item)))
const readyTaskIdSet = new Set(
(Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item)),
)
if (normalizedTasks.length === 0) {
return {
@@ -1,17 +1,11 @@
import type { JsonObject } from '../../types/json.js'
import {
listCloudtentaclesSources,
} from '../platforms/cloudtentacles/source-config-service.js'
import {
getCloudtentaclesSessionStateByKey,
} from '../platforms/cloudtentacles/session-state-service.js'
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js'
import {
normalizeCloudtentaclesDeviceId,
normalizeCloudtentaclesDeviceType,
} from '../platforms/cloudtentacles/defaults.js'
import {
listCloudtentaclesSku,
} from '../platforms/cloudtentacles/catalog-service.js'
import { listCloudtentaclesSku } from '../platforms/cloudtentacles/catalog-service.js'
import {
resolveCloudtentaclesOverrideRule,
type CloudtentaclesOverrideRule,
@@ -79,9 +73,11 @@ export async function resolveCloudtentaclesSkuByProductName(
}
const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources
const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
const getSessionByKey =
deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey
const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku
const resolveOverrideRule = deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule
const resolveOverrideRule =
deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule
const sourcesConfig = listSources()
if (sourcesConfig.enabled === false) {
@@ -89,8 +85,13 @@ export async function resolveCloudtentaclesSkuByProductName(
}
const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : [])
.map((source: CloudtentaclesSource) => buildCloudtentaclesSourceContext(source, getSessionByKey))
.filter((context): context is NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>> => Boolean(context))
.map((source: CloudtentaclesSource) =>
buildCloudtentaclesSourceContext(source, getSessionByKey),
)
.filter(
(context): context is NonNullable<ReturnType<typeof buildCloudtentaclesSourceContext>> =>
Boolean(context),
)
if (sourceContexts.length === 0) {
return null
@@ -98,7 +99,13 @@ export async function resolveCloudtentaclesSkuByProductName(
const overrideRule = resolveOverrideRule(productName)
const overrideMatch = overrideRule
? await resolveOverrideMatch(productName, normalizedProductName, overrideRule, sourceContexts, listSku)
? await resolveOverrideMatch(
productName,
normalizedProductName,
overrideRule,
sourceContexts,
listSku,
)
: null
if (overrideMatch) {
return overrideMatch
@@ -215,7 +222,8 @@ async function resolveOverrideMatch(
}
const firstDeliveryItem = deliveryItems[0]
const firstSku = items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {}
const firstSku =
items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {}
const cloudSourceKeys = rule.sourceKey
? [context.sourceKey]
: sourceContexts.map((sourceContext) => sourceContext.sourceKey)
@@ -246,13 +254,17 @@ async function resolveOverrideMatch(
return null
}
function resolveOverrideDeliveryItems(rule: CloudtentaclesOverrideRule, skuItems: CloudtentaclesSku[]) {
function resolveOverrideDeliveryItems(
rule: CloudtentaclesOverrideRule,
skuItems: CloudtentaclesSku[],
) {
const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = []
for (const ruleItem of rule.deliveryItems) {
const matchedSku = ruleItem.cloudSkuId > 0
? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId)
: findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName)
const matchedSku =
ruleItem.cloudSkuId > 0
? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId)
: findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName)
const cloudSkuId = Number(matchedSku?.id || 0)
const cloudSkuName = String(matchedSku?.name || '').trim()
@@ -1,9 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
normalizeCloudtentaclesOverrideRuleConfig,
} from './cloudtentacles-override-rule-service.js'
import { normalizeCloudtentaclesOverrideRuleConfig } from './cloudtentacles-override-rule-service.js'
test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => {
const config = normalizeCloudtentaclesOverrideRuleConfig({
@@ -3,10 +3,7 @@ import type { JsonObject } from '../../types/json.js'
import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js'
import { PROJECT_ROOT } from '../../config/runtime.js'
import {
readAppConfigEntry,
saveAppConfigEntry,
} from '../config/app-config-store.js'
import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js'
import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js'
const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join(
@@ -49,7 +46,9 @@ export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRul
})
}
export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): Promise<CloudtentaclesOverrideRuleConfig> {
export function saveCloudtentaclesOverrideRuleConfig(
rawValue: unknown,
): Promise<CloudtentaclesOverrideRuleConfig> {
return saveAppConfigEntry({
configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules,
legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH,
@@ -69,14 +68,19 @@ export function resolveCloudtentaclesOverrideRule(productName: unknown) {
return null
}
return config.rules.find((rule) =>
rule.enabled !== false &&
rule.normalizedProductName === normalizedProductName &&
rule.deliveryItems.length > 0
) || null
return (
config.rules.find(
(rule) =>
rule.enabled !== false &&
rule.normalizedProductName === normalizedProductName &&
rule.deliveryItems.length > 0,
) || null
)
}
export function normalizeCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig {
export function normalizeCloudtentaclesOverrideRuleConfig(
rawValue: unknown,
): CloudtentaclesOverrideRuleConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
const rules = Array.isArray(source.rules) ? source.rules : []
@@ -142,7 +146,10 @@ function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) {
const merged = new Map<string, CloudtentaclesOverrideDeliveryItem>()
for (const item of items) {
const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}`
const key =
item.cloudSkuId > 0
? `id:${item.cloudSkuId}`
: `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}`
const existing = merged.get(key)
if (existing) {
existing.quantity += item.quantity
@@ -13,7 +13,9 @@ type CreatedTaskInput = {
[key: string]: unknown
}
function stubOrder(patch: Partial<OrderRow> & Pick<OrderRow, 'id' | 'platform_order_id'>): OrderRow {
function stubOrder(
patch: Partial<OrderRow> & Pick<OrderRow, 'id' | 'platform_order_id'>,
): OrderRow {
return {
id: patch.id,
order_id: patch.id,
@@ -10,10 +10,7 @@ import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js
import type { FulfillmentPrepareDeps } from '../fulfillment/executors/types.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import {
TASK_STATUS,
resolveInitialPaidTaskStatus,
} from '../../domain/task-status.js'
import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
type ClaimTokenLike = {
@@ -152,10 +149,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
return preparedTasks.filter(isTaskRow)
}
async function preparePaidTasks(
tasks: TaskRow[],
deps: FulfillmentPrepareDeps,
) {
async function preparePaidTasks(tasks: TaskRow[], deps: FulfillmentPrepareDeps) {
const preparedTasks = await Promise.all(
tasks.map((task) => preparePaidFulfillmentTask(task, deps)),
)
@@ -1,10 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
isOrderItemConfiguredForFulfillment,
mergeSourceOrderState,
} from './order-service.js'
import { isOrderItemConfiguredForFulfillment, mergeSourceOrderState } from './order-service.js'
import type { OrderItemRow } from '../../types/repository/rows.js'
function buildOrderItem(snapshot: OrderItemRow['item_snapshot_json']): OrderItemRow {
@@ -20,17 +17,11 @@ function buildOrderItem(snapshot: OrderItemRow['item_snapshot_json']): OrderItem
}
test('isOrderItemConfiguredForFulfillment accepts PostgreSQL JSONB object results', () => {
assert.equal(
isOrderItemConfiguredForFulfillment(buildOrderItem({ isConfigured: true })),
true,
)
assert.equal(isOrderItemConfiguredForFulfillment(buildOrderItem({ isConfigured: true })), true)
})
test('isOrderItemConfiguredForFulfillment accepts serialized snapshots', () => {
assert.equal(
isOrderItemConfiguredForFulfillment(buildOrderItem('{"isConfigured":true}')),
true,
)
assert.equal(isOrderItemConfiguredForFulfillment(buildOrderItem('{"isConfigured":true}')), true)
})
test('isOrderItemConfiguredForFulfillment rejects unconfigured or malformed snapshots', () => {
@@ -297,7 +297,7 @@ function parseOrderItemSnapshot(value: unknown): Record<string, unknown> | null
try {
const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
? (parsed as Record<string, unknown>)
: null
} catch {
return null
@@ -21,14 +21,19 @@ export function getAffiliateDashConfig(overrides: Partial<AffiliateDashRuntimeCo
return {
enabled: config.enabled !== false,
baseUrl: String(config.baseUrl || '').trim().replace(/\/+$/, ''),
baseUrl: String(config.baseUrl || '')
.trim()
.replace(/\/+$/, ''),
appKey: String(config.appKey || '').trim(),
appSecret: String(config.appSecret || '').trim(),
callbackSecret: String(config.callbackSecret || '').trim(),
timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000),
notifyUrl: String(config.notifyUrl || '').trim(),
timestampToleranceSeconds:
Math.max(1, Number(config.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS) || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS),
timestampToleranceSeconds: Math.max(
1,
Number(config.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS) ||
AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS,
),
preferredMatchEnabled: config.preferredMatchEnabled !== false,
skuMapping: isRecord(config.skuMapping) ? config.skuMapping : {},
}
@@ -95,15 +95,18 @@ export async function affiliateDashRequest(input: {
logExternalHttpPacket('[affiliate-dash/http]', 'HTTP 响应失败', responsePacket, {
level: 'warn',
})
throw createHttpError(summarizeAffiliateDashMessage(json) || `affiliate-dash 请求失败: HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'affiliate_dash_http_failed',
context: {
status: response.status,
message: summarizeAffiliateDashMessage(json),
body: text,
throw createHttpError(
summarizeAffiliateDashMessage(json) || `affiliate-dash 请求失败: HTTP ${response.status}`,
{
statusCode: 502,
errorCode: 'affiliate_dash_http_failed',
context: {
status: response.status,
message: summarizeAffiliateDashMessage(json),
body: text,
},
},
})
)
}
if (!isAffiliateDashSuccessResponse(json)) {
@@ -70,10 +70,7 @@ export async function getAffiliateDashDelivery(orderNo: string) {
return mapAffiliateDashDeliveryInfo(json.data)
}
export async function bindAffiliateDashDelivery(input: {
orderNo: string
gameAccount: string
}) {
export async function bindAffiliateDashDelivery(input: { orderNo: string; gameAccount: string }) {
const json = await affiliateDashRequest({
method: 'POST',
pathname: `${ORDERS_PATH}/${encodeURIComponent(input.orderNo)}/delivery/bind`,
@@ -82,10 +79,7 @@ export async function bindAffiliateDashDelivery(input: {
return mapAffiliateDashBindResult(json.data)
}
export async function getAffiliateDashBindResult(input: {
orderNo: string
bindUuid: string
}) {
export async function getAffiliateDashBindResult(input: { orderNo: string; bindUuid: string }) {
const json = await affiliateDashRequest({
method: 'GET',
pathname:
@@ -3,10 +3,12 @@ import { affiliateDashRequest } from './http-client.js'
export type AffiliateDashProduct = ReturnType<typeof mapAffiliateDashProduct>
export type AffiliateDashProductListResult = ReturnType<typeof mapAffiliateDashProductList>
export async function listAffiliateDashProducts(input: {
page?: number | undefined
size?: number | undefined
} = {}) {
export async function listAffiliateDashProducts(
input: {
page?: number | undefined
size?: number | undefined
} = {},
) {
const params = new URLSearchParams()
if (input.page !== undefined) {
params.set('page', String(input.page))
@@ -23,9 +25,11 @@ export async function listAffiliateDashProducts(input: {
}
/** 翻页拉取全部可售商品(默认每页 100)。 */
export async function listAllAffiliateDashProducts(input: {
pageSize?: number | undefined
} = {}) {
export async function listAllAffiliateDashProducts(
input: {
pageSize?: number | undefined
} = {},
) {
const size = Math.max(1, Number(input.pageSize || 100) || 100)
const products: AffiliateDashProduct[] = []
let page = 1
@@ -86,10 +86,7 @@ test('verifyCallbackSign accepts valid sign and rejects tampering', () => {
const timestamp = '1783394218'
const sign = buildCallbackSign({ callbackSecret, rawBody, timestamp })
assert.equal(
verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign }),
true,
)
assert.equal(verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign }), true)
assert.equal(
verifyCallbackSign({
callbackSecret,
@@ -96,7 +96,12 @@ export function verifyCallbackSign(input: {
timestamp: input.timestamp,
})
return timingSafeEqualString(expected, String(input.sign || '').trim().toLowerCase())
return timingSafeEqualString(
expected,
String(input.sign || '')
.trim()
.toLowerCase(),
)
}
export function timingSafeEqualString(left: string, right: string): boolean {
@@ -35,7 +35,9 @@ export function getAffiliateDashSourceConfig(): AffiliateDashSourceConfig {
})
}
export function saveAffiliateDashSourceConfig(rawValue: unknown): Promise<AffiliateDashSourceConfig> {
export function saveAffiliateDashSourceConfig(
rawValue: unknown,
): Promise<AffiliateDashSourceConfig> {
return saveAppConfigEntry({
configKey: AFFILIATE_DASH_CONFIG_KEY,
value: rawValue,
@@ -48,7 +50,9 @@ export function normalizeAffiliateDashSourceConfig(rawValue: unknown): Affiliate
return {
enabled: source.enabled !== false,
baseUrl: String(source.baseUrl || '').trim().replace(/\/+$/, ''),
baseUrl: String(source.baseUrl || '')
.trim()
.replace(/\/+$/, ''),
appKey: String(source.appKey || '').trim(),
appSecret: String(source.appSecret || '').trim(),
callbackSecret: String(source.callbackSecret || '').trim(),
@@ -59,7 +59,8 @@ test('verifyAffiliateDashCallback rejects tampered body', () => {
assert.throws(
() => verifyAffiliateDashCallback(input, { callbackSecret: CALLBACK_SECRET }),
(error: Error & { errorCode?: string }) => error.errorCode === 'affiliate_dash_callback_sign_invalid',
(error: Error & { errorCode?: string }) =>
error.errorCode === 'affiliate_dash_callback_sign_invalid',
)
})
@@ -107,10 +108,9 @@ test('verifyAffiliateDashCallback passes non-JSON body as long as sign matches',
const timestamp = String(Math.floor(Date.now() / 1000))
const rawBody = 'not-json'
const result = verifyAffiliateDashCallback(
makeCallback({ rawBody, timestamp }),
{ callbackSecret: CALLBACK_SECRET },
)
const result = verifyAffiliateDashCallback(makeCallback({ rawBody, timestamp }), {
callbackSecret: CALLBACK_SECRET,
})
assert.equal(result.event, '')
assert.deepEqual(result.data, {})
@@ -75,12 +75,14 @@ export function verifyAffiliateDashCallback(
})
}
if (!verifyCallbackSign({
callbackSecret: config.callbackSecret,
rawBody,
timestamp,
sign,
})) {
if (
!verifyCallbackSign({
callbackSecret: config.callbackSecret,
rawBody,
timestamp,
sign,
})
) {
throw createHttpError('affiliate-dash 回调验签失败', {
statusCode: 401,
errorCode: 'affiliate_dash_callback_sign_invalid',
@@ -4,7 +4,11 @@ import { resolveCloudtentaclesConfig } from './helpers.js'
import type { JsonObject } from '../../../types/json.js'
export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
const token = requireToken(payload.token, 'cloudtentacles 余额查询缺少 token', 'cloudtentacles_asset_missing_token')
const token = requireToken(
payload.token,
'cloudtentacles 余额查询缺少 token',
'cloudtentacles_asset_missing_token',
)
const config = resolveCloudtentaclesConfig(payload)
const result = await cloudtentaclesRequest(config.assetPath, {
@@ -14,7 +18,10 @@ export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
contentType: 'application/json',
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
accountLabel: payload.accountLabel,
context: { operation: 'asset_get', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
context: {
operation: 'asset_get',
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
},
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_asset_failed',
})
@@ -27,7 +34,11 @@ export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
}
export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
const token = requireToken(payload.token, 'cloudtentacles 分类查询缺少 token', 'cloudtentacles_categories_missing_token')
const token = requireToken(
payload.token,
'cloudtentacles 分类查询缺少 token',
'cloudtentacles_categories_missing_token',
)
const config = resolveCloudtentaclesConfig(payload)
const result = await cloudtentaclesRequest(config.categoriesPath, {
@@ -37,7 +48,10 @@ export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
contentType: 'application/json',
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
accountLabel: payload.accountLabel,
context: { operation: 'categories_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
context: {
operation: 'categories_list',
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
},
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_categories_failed',
})
@@ -53,7 +67,11 @@ export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
}
export async function listCloudtentaclesSku(payload: JsonObject = {}) {
const token = requireToken(payload.token, 'cloudtentacles SKU 列表查询缺少 token', 'cloudtentacles_sku_list_missing_token')
const token = requireToken(
payload.token,
'cloudtentacles SKU 列表查询缺少 token',
'cloudtentacles_sku_list_missing_token',
)
const config = resolveCloudtentaclesConfig(payload)
const result = await cloudtentaclesRequest(config.skuListPath, {
@@ -63,7 +81,10 @@ export async function listCloudtentaclesSku(payload: JsonObject = {}) {
contentType: 'application/json',
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
accountLabel: payload.accountLabel,
context: { operation: 'sku_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
context: {
operation: 'sku_list',
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
},
businessErrorStatusCode: 401,
businessErrorCode: 'cloudtentacles_sku_list_failed',
})
@@ -79,8 +100,16 @@ export async function listCloudtentaclesSku(payload: JsonObject = {}) {
}
export async function buyCloudtentaclesSku(payload: JsonObject = {}) {
const token = requireToken(payload.token, 'cloudtentacles 购买 SKU 缺少 token', 'cloudtentacles_sku_buy_missing_token')
const skuId = requireId(payload.id, 'cloudtentacles 购买 SKU 缺少商品 id', 'cloudtentacles_sku_buy_missing_id')
const token = requireToken(
payload.token,
'cloudtentacles 购买 SKU 缺少 token',
'cloudtentacles_sku_buy_missing_token',
)
const skuId = requireId(
payload.id,
'cloudtentacles 购买 SKU 缺少商品 id',
'cloudtentacles_sku_buy_missing_id',
)
const count = requireCount(payload.count)
const config = resolveCloudtentaclesConfig(payload)
@@ -114,8 +143,16 @@ export async function buyCloudtentaclesSku(payload: JsonObject = {}) {
}
export async function useCloudtentaclesSku(payload: JsonObject = {}) {
const token = requireToken(payload.token, 'cloudtentacles 发货缺少 token', 'cloudtentacles_sku_use_missing_token')
const skuId = requireId(payload.id, 'cloudtentacles 发货缺少商品 id', 'cloudtentacles_sku_use_missing_id')
const token = requireToken(
payload.token,
'cloudtentacles 发货缺少 token',
'cloudtentacles_sku_use_missing_token',
)
const skuId = requireId(
payload.id,
'cloudtentacles 发货缺少商品 id',
'cloudtentacles_sku_use_missing_id',
)
const virtualNumberId = requireId(
payload.virtualNumberId,
'cloudtentacles 发货缺少虚拟号 id',
@@ -5,7 +5,9 @@ import { createHttpError } from '../../../utils/http.js'
import { resolveCloudtentaclesConfig } from './helpers.js'
export function md5CloudtentaclesPassword(password: unknown) {
return createHash('md5').update(String(password || ''), 'utf8').digest('hex')
return createHash('md5')
.update(String(password || ''), 'utf8')
.digest('hex')
}
export function encryptCloudtentaclesPayload(payload: JsonObject = {}, options: JsonObject = {}) {
@@ -20,7 +22,8 @@ export function encryptCloudtentaclesPayload(payload: JsonObject = {}, options:
}
const timestamp = Number(options.timestamp || Date.now())
const randomValue = String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16)
const randomValue =
String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16)
const normalizedPayload = removeEmptyFields(payload)
const plaintext = JSON.stringify({
t: timestamp,
@@ -59,6 +62,8 @@ function removeEmptyFields(payload: unknown) {
}
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== '' && value !== null && typeof value !== 'undefined'),
Object.entries(payload).filter(
([, value]) => value !== '' && value !== null && typeof value !== 'undefined',
),
)
}
@@ -1,5 +1,9 @@
import { createHttpError } from '../../../utils/http.js'
import { getCloudtentaclesAsset, listCloudtentaclesSku, buyCloudtentaclesSku } from './catalog-service.js'
import {
getCloudtentaclesAsset,
listCloudtentaclesSku,
buyCloudtentaclesSku,
} from './catalog-service.js'
import { getCloudtentaclesKnapsack } from './knapsack-service.js'
import type { JsonObject } from '../../../types/json.js'
import {
@@ -55,15 +59,22 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
}
await sleep(350)
const beforeKnapsack = await runFlowStep('查询购买前背包', () => getCloudtentaclesKnapsack(payload))
const beforeKnapsack = await runFlowStep('查询购买前背包', () =>
getCloudtentaclesKnapsack(payload),
)
const beforeItem = findKnapsackItem(beforeKnapsack.items, skuId)
await sleep(500)
const buyResult = await runFlowStep('购买 SKU', () => buyCloudtentaclesSku({
...payload,
id: skuId,
count: skuCount,
}), { retries: 1, retryDelayMs: 1200 })
const buyResult = await runFlowStep(
'购买 SKU',
() =>
buyCloudtentaclesSku({
...payload,
id: skuId,
count: skuCount,
}),
{ retries: 1, retryDelayMs: 1200 },
)
await sleep(1200)
const afterAsset = await runFlowStep('查询购买后余额', () => getCloudtentaclesAsset(payload), {
@@ -71,10 +82,14 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
retryDelayMs: 1200,
})
await sleep(1200)
const afterKnapsack = await runFlowStep('查询购买后背包', () => getCloudtentaclesKnapsack(payload), {
retries: 2,
retryDelayMs: 1200,
})
const afterKnapsack = await runFlowStep(
'查询购买后背包',
() => getCloudtentaclesKnapsack(payload),
{
retries: 2,
retryDelayMs: 1200,
},
)
const afterItem = findKnapsackItem(afterKnapsack.items, skuId)
const beforeCount = Number(beforeItem?.count || 0)
@@ -82,17 +97,25 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
const knapsackIncreased = afterCount >= beforeCount + skuCount
if (!knapsackIncreased) {
throw createHttpError(`购买后背包校验失败,购买前 ${beforeCount},购买后 ${afterCount},期望至少 ${beforeCount + skuCount}`, {
statusCode: 409,
errorCode: 'cloudtentacles_full_flow_knapsack_not_updated',
})
throw createHttpError(
`购买后背包校验失败,购买前 ${beforeCount},购买后 ${afterCount},期望至少 ${beforeCount + skuCount}`,
{
statusCode: 409,
errorCode: 'cloudtentacles_full_flow_knapsack_not_updated',
},
)
}
await sleep(700)
const appointed = await runFlowStep('申请虚拟号', () => appointCloudtentaclesVirtualNumber({
...payload,
key: vnKey,
}), { retries: 1, retryDelayMs: 1200 })
const appointed = await runFlowStep(
'申请虚拟号',
() =>
appointCloudtentaclesVirtualNumber({
...payload,
key: vnKey,
}),
{ retries: 1, retryDelayMs: 1200 },
)
const appointedId = Number(appointed.item?.id || 0)
const appointedPhone = String(appointed.item?.phone || '').trim()
@@ -104,33 +127,53 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
}
await sleep(1200)
const generateCodeResult = await runFlowStep('生成登录码', () => generateCloudtentaclesLoginCode({
...payload,
key: vnKey,
id: appointedId,
}), { retries: 1, retryDelayMs: 1200 })
const generateCodeResult = await runFlowStep(
'生成登录码',
() =>
generateCloudtentaclesLoginCode({
...payload,
key: vnKey,
id: appointedId,
}),
{ retries: 1, retryDelayMs: 1200 },
)
await sleep(1600)
const fetchedCode = await runFlowStep('获取验证码', () => fetchCloudtentaclesVirtualNumberCode({
...payload,
key: vnKey,
phone: appointedPhone,
}), { retries: 2, retryDelayMs: 1500 })
const fetchedCode = await runFlowStep(
'获取验证码',
() =>
fetchCloudtentaclesVirtualNumberCode({
...payload,
key: vnKey,
phone: appointedPhone,
}),
{ retries: 2, retryDelayMs: 1500 },
)
await sleep(1000)
const verified = await runFlowStep('校验验证码', () => verifyCloudtentaclesLoginCode({
...payload,
key: vnKey,
id: appointedId,
code: fetchedCode.code,
}), { retries: 1, retryDelayMs: 1200 })
const verified = await runFlowStep(
'校验验证码',
() =>
verifyCloudtentaclesLoginCode({
...payload,
key: vnKey,
id: appointedId,
code: fetchedCode.code,
}),
{ retries: 1, retryDelayMs: 1200 },
)
await sleep(1000)
const bindUrlResult = await runFlowStep('获取兑换链接', () => getCloudtentaclesBindUrl({
...payload,
key: vnKey,
id: appointedId,
}), { retries: 1, retryDelayMs: 1200 })
const bindUrlResult = await runFlowStep(
'获取兑换链接',
() =>
getCloudtentaclesBindUrl({
...payload,
key: vnKey,
id: appointedId,
}),
{ retries: 1, retryDelayMs: 1200 },
)
return {
sku: {
@@ -173,7 +216,10 @@ function normalizePositiveInteger(value: unknown, fallback: number) {
}
function findKnapsackItem(items: unknown, skuId: unknown) {
return (Array.isArray(items) ? items : []).find((item) => Number(item.id || 0) === Number(skuId)) || null
return (
(Array.isArray(items) ? items : []).find((item) => Number(item.id || 0) === Number(skuId)) ||
null
)
}
async function runFlowStep<T>(
@@ -184,7 +230,8 @@ async function runFlowStep<T>(
const rawRetries = Number(options.retries)
const rawRetryDelayMs = Number(options.retryDelayMs)
const retries = Number.isInteger(rawRetries) && rawRetries > 0 ? rawRetries : 0
const retryDelayMs = Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000
const retryDelayMs =
Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
@@ -221,7 +268,9 @@ function wrapStepError(label: string, error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
const statusCode = Number(Reflect.get(error, 'statusCode') || 500)
const errorCode = String(Reflect.get(error, 'errorCode') || 'cloudtentacles_full_flow_step_failed')
const errorCode = String(
Reflect.get(error, 'errorCode') || 'cloudtentacles_full_flow_step_failed',
)
return createHttpError(`cloudtentacles ${label}失败:${message}`, {
statusCode,
@@ -1,17 +1,12 @@
export const DEFAULT_CLOUDTENTACLES_DEVICE_ID =
"08bc9d8c-fd15-48ea-bc00-8d754076cafc";
export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1;
export const DEFAULT_CLOUDTENTACLES_DEVICE_ID = '08bc9d8c-fd15-48ea-bc00-8d754076cafc'
export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1
export function normalizeCloudtentaclesDeviceId(value: unknown) {
const normalized = String(value || "").trim();
return normalized && normalized !== "-"
? normalized
: DEFAULT_CLOUDTENTACLES_DEVICE_ID;
const normalized = String(value || '').trim()
return normalized && normalized !== '-' ? normalized : DEFAULT_CLOUDTENTACLES_DEVICE_ID
}
export function normalizeCloudtentaclesDeviceType(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0
? parsed
: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE;
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CLOUDTENTACLES_DEVICE_TYPE
}
@@ -52,46 +52,98 @@ export function resolveCloudtentaclesConfig(overrides: Partial<CloudtentaclesRun
return {
baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl || 'https://123.207.217.176'),
timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000),
sendSmsPath: normalizePath(overrides.sendSmsPath || baseConfig.sendSmsPath, '/public/verif_code'),
sendSmsPath: normalizePath(
overrides.sendSmsPath || baseConfig.sendSmsPath,
'/public/verif_code',
),
loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/public/login'),
userInfoPath: normalizePath(overrides.userInfoPath || baseConfig.userInfoPath, '/user/info'),
assetPath: normalizePath(overrides.assetPath || baseConfig.assetPath, '/user/get_asset'),
permissionPath: normalizePath(overrides.permissionPath || baseConfig.permissionPath, '/user/get_permission'),
categoriesPath: normalizePath(overrides.categoriesPath || baseConfig.categoriesPath, '/categories/get'),
permissionPath: normalizePath(
overrides.permissionPath || baseConfig.permissionPath,
'/user/get_permission',
),
categoriesPath: normalizePath(
overrides.categoriesPath || baseConfig.categoriesPath,
'/categories/get',
),
skuListPath: normalizePath(overrides.skuListPath || baseConfig.skuListPath, '/sku/list'),
skuBuyPath: normalizePath(overrides.skuBuyPath || baseConfig.skuBuyPath, '/sku/buy'),
skuUsePath: normalizePath(overrides.skuUsePath || baseConfig.skuUsePath, '/sku/use'),
knapsackPath: normalizePath(overrides.knapsackPath || baseConfig.knapsackPath, '/user/get_knapsack'),
knapsackPath: normalizePath(
overrides.knapsackPath || baseConfig.knapsackPath,
'/user/get_knapsack',
),
vnListPath: normalizePath(overrides.vnListPath || baseConfig.vnListPath, '/vn/list'),
vnAppointPath: normalizePath(overrides.vnAppointPath || baseConfig.vnAppointPath, '/vn/appoint'),
vnAppointPath: normalizePath(
overrides.vnAppointPath || baseConfig.vnAppointPath,
'/vn/appoint',
),
vnGenerateLoginCodePath: normalizePath(
overrides.vnGenerateLoginCodePath || baseConfig.vnGenerateLoginCodePath,
'/vn/generate_login_code',
),
vnVerifCodePath: normalizePath(overrides.vnVerifCodePath || baseConfig.vnVerifCodePath, '/public/vn_verif_code'),
vnVerifCodePath: normalizePath(
overrides.vnVerifCodePath || baseConfig.vnVerifCodePath,
'/public/vn_verif_code',
),
vnVerifyLoginCodePath: normalizePath(
overrides.vnVerifyLoginCodePath || baseConfig.vnVerifyLoginCodePath,
'/vn/verify_login_code',
),
vnBindUrlPath: normalizePath(overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, '/vn/bind_url'),
vnBindInfoPath: normalizePath(overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, '/vn/bind_info'),
vnBindUrlPath: normalizePath(
overrides.vnBindUrlPath || baseConfig.vnBindUrlPath,
'/vn/bind_url',
),
vnBindInfoPath: normalizePath(
overrides.vnBindInfoPath || baseConfig.vnBindInfoPath,
'/vn/bind_info',
),
vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'),
bindUrlTtlSeconds: normalizePositiveInteger(overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, 600),
bindUrlTtlSeconds: normalizePositiveInteger(
overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds,
600,
),
bindUrlProbeIntervalSeconds: normalizePositiveInteger(
overrides.bindUrlProbeIntervalSeconds || baseConfig.bindUrlProbeIntervalSeconds,
30,
),
bindUrlProbeTimeoutMs: normalizePositiveInteger(overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, 5000),
bindUrlProbeUserAgent: String(overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '').trim(),
bindUrlProbeEndpoint: String(overrides.bindUrlProbeEndpoint || baseConfig.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim(),
bindUrlProbeChartId: String(overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794').trim(),
bindUrlProbeSubChartId: String(overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794').trim(),
bindUrlProbeIdeToken: String(overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo').trim(),
bindUrlProbeActivityUrl: String(overrides.bindUrlProbeActivityUrl || baseConfig.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim(),
bindUrlProbeReferer: String(overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/').trim(),
bindUrlProbeExtraCookie: String(overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '').trim(),
bindUrlProbeTimeoutMs: normalizePositiveInteger(
overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs,
5000,
),
bindUrlProbeUserAgent: String(
overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '',
).trim(),
bindUrlProbeEndpoint: String(
overrides.bindUrlProbeEndpoint ||
baseConfig.bindUrlProbeEndpoint ||
'https://comm.ams.game.qq.com/ide/',
).trim(),
bindUrlProbeChartId: String(
overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794',
).trim(),
bindUrlProbeSubChartId: String(
overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794',
).trim(),
bindUrlProbeIdeToken: String(
overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo',
).trim(),
bindUrlProbeActivityUrl: String(
overrides.bindUrlProbeActivityUrl ||
baseConfig.bindUrlProbeActivityUrl ||
'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
).trim(),
bindUrlProbeReferer: String(
overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/',
).trim(),
bindUrlProbeExtraCookie: String(
overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '',
).trim(),
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
clientSource:
String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() ||
'ct-client',
deviceId: normalizeCloudtentaclesDeviceId(overrides.deviceId ?? baseConfig.deviceId),
deviceType: normalizeCloudtentaclesDeviceType(overrides.deviceType ?? baseConfig.deviceType),
}
@@ -102,7 +154,10 @@ export function buildCloudtentaclesUrl(
pathname: unknown,
searchParams: JsonObject | null = null,
) {
const url = new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || 'https://123.207.217.176')
const url = new URL(
normalizePath(pathname, '/'),
normalizeBaseUrl(baseUrl) || 'https://123.207.217.176',
)
if (searchParams && typeof searchParams === 'object') {
for (const [key, value] of Object.entries(searchParams)) {
@@ -146,7 +201,9 @@ export function buildCloudtentaclesHeaders({
}
function normalizeBaseUrl(value: unknown) {
return String(value || '').trim().replace(/\/+$/, '')
return String(value || '')
.trim()
.replace(/\/+$/, '')
}
function normalizePath(value: unknown, fallback: string) {
@@ -175,7 +232,12 @@ function normalizeHeaderMap(extra: unknown) {
return Object.fromEntries(
Object.entries(extra)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.map(([key, value]) => [
String(key || '')
.trim()
.toLowerCase(),
String(value || '').trim(),
])
.filter(([key, value]) => key && value),
)
}
@@ -24,9 +24,8 @@ test('cloudtentaclesRequest retries high-frequency business errors', async () =>
const server = await createMockServer(() => {
callCount += 1
return {
body: callCount === 1
? { code: 1, message: 'high-frequency Request' }
: { code: 0, data: 'ok' },
body:
callCount === 1 ? { code: 1, message: 'high-frequency Request' } : { code: 0, data: 'ok' },
}
})
@@ -155,15 +154,16 @@ async function createMockServer(handler: () => MockResponse) {
return {
baseUrl: `http://127.0.0.1:${address?.port}`,
close: () => new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
}),
resolve()
})
}),
}
}

Some files were not shown because too many files have changed in this diff Show More