删除咸鱼旧链路

This commit is contained in:
yml
2026-05-25 21:46:39 +08:00
parent 9aafddd624
commit e7aa194dde
142 changed files with 539 additions and 17373 deletions
@@ -1,434 +0,0 @@
// @ts-check
import { createTaskClaimToken } from '../claim/claim-service.js'
import {
closeClaimSessionForAdminTask,
createClaimSessionForAdminTask,
getClaimDetailForAdminTask,
getClaimSessionSummaryForAdminTask,
reloadClaimSessionForAdminTask,
} from '../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { createHttpError } from '../../utils/http.js'
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js'
import type { TaskRow } from '../../types/repository-rows.js'
const MANUAL_PROVIDER = 'manual'
const MANUAL_PLATFORM = 'manual_redeem'
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
type JsonObject = Record<string, any>
export async function createAdminManualRedeemTask(
payload: AdminManualRedeemCreateInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const viewerContext = createAdminViewerContext(session)
const proofValue = normalizeManualProofValue(payload.proofValue)
const skuCode = String(payload.skuCode || '').trim()
const skuName = String(payload.skuName || '').trim() || skuCode
const remark = String(payload.remark || '').trim()
if (!proofValue) {
throw createHttpError('请先输入唯一凭据', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_proof',
})
}
if (!skuCode) {
throw createHttpError('请先选择内部履约 SKU', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_sku',
})
}
const existingOrder = await findOrderByPlatformOrderId({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
platformOrderId: proofValue,
})
if (existingOrder) {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
if (!profile) {
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
statusCode: 500,
errorCode: 'admin_manual_redeem_profile_missing',
})
}
const requirements = await listFulfillmentProfileRequirements(profile.id)
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
skuCode,
credentialType,
viewerContext.allowedInventoryGroupCodes,
)
if (!availableInventory) {
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_unavailable',
})
}
const now = nowIso()
const manualContext = {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
remark,
createdAt: now,
createdBy: session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null,
}
let order
try {
order = await createOrder({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
orderStatus: 'manual_created',
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
rawPayloadJson: JSON.stringify({
manualRedeem: manualContext,
}),
paidAt: now,
createdAt: now,
updatedAt: now,
})
} catch (error) {
if (String(error?.code || '') === '23505') {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
throw error
}
if (!order) {
throw createHttpError('人工兑换订单创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_create_failed',
})
}
const orderItems = await replaceOrderItems(order.id, [
{
skuCode,
skuName,
quantity: 1,
specJson: JSON.stringify({
title: skuName,
sourceType: MANUAL_SOURCE_TYPE,
}),
itemSnapshotJson: JSON.stringify({
manualRedeem: manualContext,
skuCode,
skuName,
}),
createdAt: now,
updatedAt: now,
},
])
const orderItem = orderItems[0]
if (!orderItem) {
throw createHttpError('人工兑换订单商品创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_item_create_failed',
})
}
const createdTask = await createTask({
orderId: order.id,
orderItemId: orderItem.id,
unitIndex: 1,
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
taskNo: randomId('MR'),
profileId: Number(profile.id),
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
taskStatus: 'paid',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
resultCode: '',
resultMessage: '',
claimToken: '',
claimExpiresAt: null,
automationMode: 'manual',
requiresClaim: true,
userActionStatus: 'pending_claim',
attemptCount: 0,
lastError: '',
contextJson: JSON.stringify({
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
profileName: String(profile.name || ''),
skuCode,
skuName,
inventorySkuCode: skuCode,
primaryRequirement: primaryRequirement
? {
roleKey,
credentialType,
}
: null,
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
manualRedeem: manualContext,
}),
createdAt: now,
updatedAt: now,
})
if (!createdTask) {
throw createHttpError('人工兑换任务创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_task_create_failed',
})
}
const reservedInventory = await reserveInventoryForTask({
skuCode,
taskId: createdTask.id,
credentialType,
roleKey,
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
if (!reservedInventory) {
await updateTask(createdTask.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '库存不足,无法为人工兑换任务预占库存',
updated_at: nowIso(),
})
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_race_lost',
})
}
const claimToken = await createTaskClaimToken(createdTask.id)
await updateTask(createdTask.id, {
task_status: 'link_generated',
inventory_status: 'reserved',
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: '',
context_json: JSON.stringify({
...parseTaskContext(createdTask),
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
}),
updated_at: nowIso(),
})
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
skuCode,
skuName,
inventoryItemId: Number(reservedInventory.id || 0) || null,
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
createdBy: manualContext.createdBy,
remark,
}, nowIso())
return getAdminManualRedeemDetail(createdTask.id, session)
}
export async function getAdminManualRedeemDetail(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimDetailForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function createAdminManualRedeemSession(
taskId: AdminEntityIdInput,
payload: { loginType?: string, forceRecreate?: boolean } = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await createClaimSessionForAdminTask(task.id, payload)
return decorateManualRedeemDetail(task, detail)
}
export async function getAdminManualRedeemSessionSummary(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimSessionSummaryForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function reloadAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await reloadClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await closeClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await closeAdminTask(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
export async function confirmAdminManualRedeemRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await confirmAdminTaskAssistedRole(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
export async function redeemAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await redeemAdminTaskAssisted(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
async function getRequiredManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<TaskRow> {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('人工兑换任务不存在', {
statusCode: 404,
errorCode: 'admin_manual_redeem_task_not_found',
})
}
const taskContext = parseTaskContext(task)
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
throw createHttpError('当前任务不是人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_task_invalid',
})
}
const viewerContext = createAdminViewerContext(session)
if (viewerContext.restrictInventoryGroups) {
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_denied',
})
}
}
return task
}
function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject {
const taskContext = parseTaskContext(task)
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
? taskContext.manualRedeem
: {}
return {
...detail,
claimUrl: '',
manualRequest: {
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
remark: String(manualRedeem.remark || '').trim(),
},
result: detail.result
? {
...detail.result,
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
}
: null,
}
}
function normalizeManualProofValue(value: unknown): string {
return String(value || '').replace(/\s+/g, ' ').trim()
}
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
import {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
? order.raw_payload_json
: JSON.parse(String(order.raw_payload_json || '{}')),
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
},
items: items.map((item) => ({
orderItemId: item.id,
@@ -1,86 +0,0 @@
export {
getRequiredInventoryItem,
mapAdminInventoryListItem,
} from './admin-inventory-read-helpers.js'
export {
mapAdminOrderListItem,
summarizeOrderItems,
} from './admin-order-read-helpers.js'
export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
export {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary,
createEmptyTaskBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
getTaskBindingSummary,
getTaskBindingSummaryMap,
mapAdminTaskEvent,
mapAdminTaskInventoryBinding,
mapAdminTaskListItem,
mapAdminTaskSummary,
mapTaskActionPayload,
} from './admin-task-read-helpers.js'
export {
canRegenerateClaimLinkForViewer,
canViewerConfirmAssistedRole,
canViewerRedeemAssistedTask,
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
mapManualDispatchContext,
mapRedeemResolutionContext,
parseTaskContext,
parseTaskState,
resolveOrderItemDeliveryMode,
resolveOrderItemTitle,
resolveAdminTaskScreenshotUrl,
resolveDisplayShopName,
} from './admin-read-shared-helpers.js'
export {
getAdminInventoryItems,
getAdminInventorySkuSuggestions,
getAdminOrderDetail,
getAdminOrders,
getAdminTaskDetail,
getAdminTasks,
getAdminTaskScreenshotPath,
getAdminWebhookEventDetail,
getAdminWebhookEvents,
} from './admin-read-service.js'
export {
closeAdminTask,
completeAdminTaskManualDispatch,
confirmAdminTaskAssistedRole,
createAdminInventoryItem,
dispatchAdminTaskKuaishouCloudFulfillment,
importAdminInventoryItems,
invalidateAdminInventoryItem,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
redeemAdminTaskAssisted,
regenerateAdminTaskClaimLink,
releaseAdminInventoryItem,
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
replayAdminWebhookEvent,
returnNumberAdminTaskKuaishouCloudFulfillment,
retryAdminTask,
} from './admin-write-service.js'
export {
getAdminAgisoShopConfigs,
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminAgisoShopConfigs,
updateAdminFulfillmentBindingConfigs,
} from './platform-config/service.js'
export { getAdminDashboardSummary } from './admin-dashboard-service.js'
export { getAdminMessageDeliveries } from './admin-message-delivery-service.js'
@@ -10,13 +10,11 @@ import {
} from './admin-read-shared-helpers.js'
import type {
AdminAgisoAutoDeliveryStatus,
AdminTaskBindingSummary,
AdminTaskListItem,
} from '../../types/admin-read-models.js'
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
import type {
OrderRow,
TaskEventRow,
TaskInventoryBindingRow,
TaskInventoryBindingSummaryRow,
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
userBindingStatus: string
}
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
totalTaskCount: number
deliveredTaskCount: number
}
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
}
export function mapAdminTaskSummary(
task: TaskRow,
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
): JsonRecord {
const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return {
taskId: task.id,
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
lastError: task.last_error,
retryCount: getTaskRetryCount(task),
bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
createdAt: task.created_at,
updatedAt: task.updated_at,
}
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
viewerContext: AdminViewerContext = createAdminViewerContext(),
): AdminTaskListItem {
const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return {
taskId: task.id,
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
redeemedAt: task.redeemed_at,
retryCount: getTaskRetryCount(task),
bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
lastError: task.last_error,
createdAt: task.created_at,
updatedAt: task.updated_at,
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
}
}
export function buildOrderAgisoAutoDeliverySummary(
order: Partial<OrderRow> | null | undefined,
tasks: TaskRow[] = [],
): OrderAgisoAutoDeliverySummary | null {
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
return null
}
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
const totalTaskCount = normalizedTasks.length
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
if (!autoDelivery) {
return best
}
const candidate = {
...autoDelivery,
sourceTaskId: Number(task.id || 0) || null,
sourceTaskNo: String(task.task_no || '').trim(),
}
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
const bestTime = Date.parse(String(best?.updatedAt || ''))
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
return candidate
}
return best
}, null)
if (latest) {
return {
...latest,
totalTaskCount,
deliveredTaskCount,
}
}
return {
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
trigger: '',
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: 0,
errorMessage: '',
requestId: '',
updatedAt: null,
sourceTaskId: null,
sourceTaskNo: '',
totalTaskCount,
deliveredTaskCount,
}
}
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
if (!task || !binding) {
return false
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
}
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
return {
status: String(record.status || '').trim(),
trigger: String(record.trigger || '').trim(),
reason: String(record.reason || '').trim(),
platformOrderId: String(record.platformOrderId || '').trim(),
responseStatus: Number(record.responseStatus || 0),
errorMessage: String(record.errorMessage || '').trim(),
requestId: String(record.requestId || '').trim(),
updatedAt: record.updatedAt || null,
}
}
function normalizeRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
@@ -1,28 +0,0 @@
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
import { createHttpError } from '../../utils/http.js'
import { getRequiredTask } from './admin-task-read-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
export async function getAdminTaskScreenshotPathWithTencentFallback(
taskId: AdminEntityIdInput,
_session: AdminViewerSessionInput | null = null,
): Promise<string> {
const task = await getRequiredTask(taskId)
if (task.screenshot_path) {
return task.screenshot_path
}
if (task.browser_session_id) {
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
}
throw createHttpError('当前任务还没有可查看截图', {
statusCode: 404,
errorCode: 'admin_task_screenshot_not_found',
})
}
@@ -1,6 +1,5 @@
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
import { safeParseJson } from './admin-query-utils.js'
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
const mapped = {
eventId: item.id,
provider: item.provider || 'agiso',
provider: item.provider || '',
platform: item.platform,
platformRaw: pickFirstNonEmpty([
query.fromPlatform,
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
relatedOrderId: item.related_order_id,
createdAt: item.created_at,
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
buyerId: pickFirstNonEmpty([
payload.buyer_id,
payload.buyerId,
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
}
function extractWebhookPayload(body: JsonRecord): JsonRecord {
return extractAgisoTradePayload(body)
const candidates = [
body.data,
body.Data,
body.payload,
body.Payload,
body.message,
body.Message,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
return normalizeRecord(candidate)
}
}
return body
}
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
const fromPayload = pickFirstNonEmpty([
payload.biz_order_id,
payload.bizOrderId,
payload.BizOrderId,
payload.tid,
payload.Tid,
payload.order_id,
payload.orderId,
payload.OrderId,
payload.oid,
payload.Oid,
])
if (fromPayload) {
return fromPayload
}
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
}
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
@@ -1,105 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { closeAdminTaskWithDeps } from './admin-write-service.js'
test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => {
const calls = {
updateToken: [],
releaseReserved: [],
closeSession: [],
updateTask: [],
createEvent: [],
}
const task = {
id: 30,
task_status: 'link_generated',
delivery_status: 'pending',
inventory_status: 'reserved',
user_action_status: 'pending_claim',
browser_session_id: 'browser-session-30',
primary_claim_token_id: 9,
claim_expires_at: '2026-04-15T10:00:00.000Z',
last_error: '',
updated_at: '2026-04-14T10:00:00.000Z',
}
const now = '2026-04-14T10:16:18.000Z'
const result = await closeAdminTaskWithDeps(task.id, {
getRequiredTask: async () => task,
listTaskInventoryBindingsByTaskId: async () => ([
{ inventory_item_id: 17, binding_status: 'reserved' },
{ inventory_item_id: 18, binding_status: 'released' },
{ inventory_item_id: 17, binding_status: 'reserved' },
]),
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
calls.releaseReserved.push({ inventoryItemId, updatedAt })
return { id: inventoryItemId }
},
updateClaimToken: async (tokenId, patch) => {
calls.updateToken.push({ tokenId, patch })
return { id: tokenId, ...patch }
},
closeTencentBrowserSession: async (sessionId) => {
calls.closeSession.push(sessionId)
return { sessionId, closed: true }
},
updateTask: async (taskId, patch) => {
calls.updateTask.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
calls.createEvent.push({ taskId, eventType, payload, createdAt })
return null
},
nowIso: () => now,
})
assert.equal(calls.updateToken.length, 1)
assert.deepEqual(calls.updateToken[0], {
tokenId: 9,
patch: {
status: 'revoked',
expired_at: now,
updated_at: now,
},
})
assert.deepEqual(calls.releaseReserved, [
{ inventoryItemId: 17, updatedAt: now },
])
assert.deepEqual(calls.closeSession, ['browser-session-30'])
assert.equal(calls.updateTask.length, 1)
assert.equal(calls.updateTask[0].patch.task_status, 'closed')
assert.equal(calls.updateTask[0].patch.delivery_status, 'closed')
assert.equal(calls.updateTask[0].patch.inventory_status, 'pending')
assert.equal(calls.updateTask[0].patch.user_action_status, 'closed')
assert.equal(calls.updateTask[0].patch.browser_session_id, '')
assert.equal(calls.updateTask[0].patch.claim_expires_at, now)
assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/)
assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/)
assert.equal(calls.createEvent.length, 1)
assert.deepEqual(calls.createEvent[0], {
taskId: 30,
eventType: 'task_closed',
payload: {
claimTokenRevoked: true,
releasedInventoryItemIds: [17],
releasedInventoryCount: 1,
browserSessionClosed: true,
},
createdAt: now,
})
assert.equal(result.task.status, 'closed')
})
test('closeAdminTaskWithDeps rejects redeemed tasks', async () => {
await assert.rejects(
() => closeAdminTaskWithDeps(99, {
getRequiredTask: async () => ({
id: 99,
task_status: 'redeemed',
}),
}),
/已兑换任务不能关闭/,
)
})
@@ -1,30 +0,0 @@
export {
createAdminInventoryItem,
importAdminInventoryItems,
releaseAdminInventoryItem,
invalidateAdminInventoryItem,
} from './write/inventory.js'
export {
replayAdminWebhookEvent,
} from './write/webhook-events.js'
export {
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
regenerateAdminTaskClaimLink,
confirmAdminTaskAssistedRole,
redeemAdminTaskAssisted,
closeAdminTask,
closeAdminTaskWithDeps,
markAdminTaskManualReview,
retryAdminTask,
completeAdminTaskManualDispatch,
} from './write/task-actions.js'
export {
prepareAdminTaskKuaishouCloudFulfillment,
dispatchAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from './write/kuaishou-cloud-actions.js'
@@ -1,65 +0,0 @@
import { query } from '../../../db/client.js'
import {
getAgisoMessagingDefaults,
getAgisoShopConfigMap,
getAgisoShopsFilePath,
saveAgisoMessagingConfig,
} from '../../platforms/agiso/shop-config-service.js'
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
import {
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
} from './domain.js'
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
type JsonObject = Record<string, any>
export async function getAdminAgisoShopConfigs() {
const configMap = getAgisoShopConfigMap()
const defaults = getAgisoMessagingDefaults()
const rowsResult = await query(
`
SELECT
shop_id,
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
MAX(created_at) AS latest_seen_at,
COUNT(*)::int AS webhook_event_count
FROM webhook_events
WHERE provider = 'agiso' AND trim(shop_id) != ''
GROUP BY shop_id
ORDER BY latest_seen_at DESC, shop_id DESC
`,
)
const rows = rowsResult.rows
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(defaults),
shops: Object.entries(configMap)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
observedShops: rows.map((row) => ({
shopId: String(row.shop_id || '').trim(),
detectedShopName: String(row.detected_shop_name || '').trim(),
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
latestSeenAt: row.latest_seen_at || null,
webhookEventCount: Number(row.webhook_event_count || 0),
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
})),
}
}
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
currentDefaults: getAgisoMessagingDefaults(),
currentMap: getAgisoShopConfigMap(),
}))
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
shops: Object.entries(saved.shops)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
}
}
@@ -1,6 +0,0 @@
export function normalizeAgisoMessageTemplate(value: unknown): string {
return String(value || "")
.replaceAll("\\r\\n", "\n")
.replaceAll("\\n", "\n")
.replaceAll("\r\n", "\n");
}
@@ -2,101 +2,15 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
applyOptionalStringField,
findMatchingObservedBinding,
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
mapAdminFulfillmentBindingConfigItem,
mapAdminObservedProductItem,
matchesObservedProduct,
} from './domain.js'
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
assert.deepEqual(
mapAdminAgisoMessagingDefaults({
messageTemplate: ' hello\\nworld ',
autoDeliveryMessageTemplate: ' done\\nnow ',
}),
{
messageTemplate: 'hello\nworld',
autoDeliveryMessageTemplate: 'done\nnow',
},
)
})
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
assert.deepEqual(
mapAdminAgisoShopConfigItem('1001', {
shopName: ' 店铺A ',
accessToken: 'abcdef1234567890',
enabled: true,
messageTemplate: ' hi\\nall ',
autoDeliveryMessageTemplate: ' ok ',
appSecret: 'secret-1',
apiVersion: ' v2 ',
sendMessageEndpoint: ' /send ',
}),
{
shopId: '1001',
shopName: '店铺A',
accessToken: 'abcdef1234567890',
accessTokenMasked: 'abcdef****567890',
enabled: true,
messageTemplate: 'hi\nall',
autoDeliveryMessageTemplate: 'ok',
appSecretConfigured: true,
apiVersion: 'v2',
sendMessageEndpoint: '/send',
},
)
})
test('applyOptionalStringField updates and clears normalized template fields', () => {
const target = { messageTemplate: 'old' }
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
assert.deepEqual(target, { messageTemplate: 'next\nline' })
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
assert.deepEqual(target, {})
})
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
assert.deepEqual(
mapAdminFulfillmentBindingConfigItem({
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
skuCode: ' sku-1 ',
priority: '80',
match: {
externalSkuCode: ' ext-1 ',
},
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '',
skuCode: 'sku-1',
skuName: '',
profileKey: '',
enabled: true,
priority: 80,
config: {},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
},
)
})
test('matchesObservedProduct honors provider platform shop and external fields', () => {
const binding = {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
match: {
externalSkuCode: 'sku-ext-1',
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal(
matchesObservedProduct(binding, {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
externalSkuCode: 'sku-ext-1',
}),
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal(
matchesObservedProduct(binding, {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-2',
externalSkuCode: 'sku-ext-1',
}),
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
test('mapAdminObservedProductItem attaches matched binding summary', () => {
const bindings = [
{
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
skuCode: 'sku-1',
skuName: 'SKU 1',
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
},
]
const observed = {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
shopName: '店铺1',
externalItemId: '',
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
shopName: '店铺1',
externalItemId: '',
@@ -1,70 +1,5 @@
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
import { maskSecret } from './mappers.js'
type JsonObject = Record<string, any>
export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) {
return {
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(defaults.autoDeliveryMessageTemplate || '').trim(),
),
}
}
export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) {
return {
shopId,
shopName: String(config.shopName || '').trim(),
accessToken: String(config.accessToken || '').trim(),
accessTokenMasked: maskSecret(config.accessToken),
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(config.autoDeliveryMessageTemplate || '').trim(),
),
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
apiVersion: String(config.apiVersion || '').trim(),
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
}
}
export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) {
if (!source || typeof source[key] !== 'string') {
return
}
const value = String(source[key] || '').trim()
if (value) {
target[key] = normalizeAgisoMessageTemplate(value)
return
}
delete target[key]
}
export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) {
const match = item?.match || {}
return {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
skuCode: String(item?.skuCode || '').trim(),
skuName: String(item?.skuName || '').trim(),
profileKey: String(item?.profileKey || '').trim(),
enabled: item?.enabled !== false,
priority: Number(item?.priority || 100),
config: item?.config || {},
match: {
externalSkuCode: String(match.externalSkuCode || '').trim(),
externalItemId: String(match.externalItemId || '').trim(),
externalSkuName: String(match.externalSkuName || '').trim(),
config: match.config || {},
},
}
}
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
const provider = String(binding?.provider || '').trim()
const platform = String(binding?.platform || '').trim()
@@ -1,103 +0,0 @@
import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js'
import {
filterLegacyOrderFulfillmentBindings,
getOrderFulfillmentBindingsFilePath,
getLegacyOrderFulfillmentBindingConfigs,
saveOrderFulfillmentBindingConfigs,
} from '../../order/fulfillment-binding-config-service.js'
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
import { createHttpError } from '../../../utils/http.js'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
import { listAdminObservedProducts } from './observed-products.js'
import { validateAdminFulfillmentBindingConfigs } from './validation.js'
import { mapAdminFulfillmentBindingConfigItem } from './domain.js'
import type {
AdminFulfillmentBindingConfigSaveInput,
AdminFulfillmentBindingLookupInput,
} from '../../../types/admin-write-inputs.js'
type JsonObject = Record<string, any>
export async function getAdminFulfillmentBindingConfigs() {
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
observedProducts: await listAdminObservedProducts(bindings),
}
}
export async function lookupAdminFulfillmentBindingOrder(
payload: AdminFulfillmentBindingLookupInput = {},
) {
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
const detailResult = await enrichAgisoXianyuTradeOrder({
provider,
platform,
shopId,
shopName: '',
platformOrderId,
orderStatus: 'created',
payStatus: 'unpaid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
paidAt: null,
rawPayload: {},
items: [],
}, {
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
})
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return buildFulfillmentLookupResult({
provider,
platform,
shopId,
platformOrderId,
detail,
detailResult,
bindings,
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
})
}
export async function updateAdminFulfillmentBindingConfigs(
payload: AdminFulfillmentBindingConfigSaveInput = {},
) {
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, {
getFulfillmentProfileByKey,
})
assertLegacyBindingConfigsOnly(normalizedBindings)
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
await syncConfiguredFulfillmentBindings()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
}
}
function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) {
if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) {
return
}
throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved',
})
}
@@ -1,124 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
assert.deepEqual(
normalizeFulfillmentLookupPayload({
shopId: ' shop-1 ',
platformOrderId: ' order-1 ',
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
},
)
assert.throws(
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
/请先填写店铺 ID/,
)
})
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
assert.throws(
() =>
resolveFulfillmentLookupDetail(
{
reason: 'missing_config',
parsed: { items: [] },
},
'order-1',
),
/当前店铺缺少订单详情查询配置/,
)
})
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
const result = buildFulfillmentLookupResult({
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
detail: {
shopName: '店铺A',
buyerName: '买家A',
totalAmount: 12345,
paidAt: '2026-05-04T10:00:00.000Z',
items: [
{
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
skuName: '礼包A',
quantity: 2,
},
],
},
detailResult: {
enriched: true,
reason: 'ok',
errorMessage: '',
},
bindings: [
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
match: {
externalSkuCode: 'sku-1',
},
},
],
fallbackShopName: '备用店铺',
})
assert.deepEqual(result, {
order: {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
platformOrderId: 'order-1',
buyerName: '买家A',
totalAmountFen: 12345,
totalAmount: '123.45',
paidAt: '2026-05-04T10:00:00.000Z',
enriched: true,
enrichReason: 'ok',
errorMessage: '',
},
items: [
{
lineId: 'order-1:1:sku-1:item-1',
itemTitle: '礼包A',
quantity: 2,
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
latestSeenAt: null,
orderItemCount: 2,
configured: true,
matchedBinding: {
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
},
},
],
})
})
@@ -1,137 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { formatFenToAmount } from '../../../utils/money.js'
import { isPlainObject, pickFirstNonEmpty } from './context.js'
import { mapAdminObservedProductItem } from './domain.js'
type JsonObject = Record<string, any>
export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) {
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
const shopId = String(payload.shopId || '').trim()
const platformOrderId = String(payload.platformOrderId || '').trim()
if (!shopId) {
throw createHttpError('请先填写店铺 ID', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
})
}
if (!platformOrderId) {
throw createHttpError('请先填写平台订单号', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
})
}
if (provider !== 'agiso' || platform !== 'xianyu') {
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
})
}
return { provider, platform, shopId, platformOrderId }
}
export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) {
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
const items = Array.isArray(detail.items) ? detail.items : []
if (items.length > 0) {
return { detail, items }
}
const detailReason = String(detailResult?.reason || '').trim()
const detailMessage = String(detailResult?.errorMessage || '').trim()
let message = detailMessage
if (!message && detailReason === 'missing_config') {
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
}
if (!message) {
message = `未查询到订单 ${platformOrderId} 的商品明细`
}
throw createHttpError(message, {
statusCode: 404,
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
})
}
export function buildFulfillmentLookupResult({
provider = '',
platform = '',
shopId = '',
platformOrderId = '',
detail = {},
detailResult = {},
bindings = [],
fallbackShopName = '',
}: {
provider?: string
platform?: string
shopId?: string
platformOrderId?: string
detail?: JsonObject
detailResult?: JsonObject
bindings?: JsonObject[]
fallbackShopName?: string
} = {}) {
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
const items = Array.isArray(detail.items) ? detail.items : []
return {
order: {
provider,
platform,
shopId,
shopName: resolvedShopName,
platformOrderId,
buyerName: String(detail.buyerName || '').trim(),
totalAmountFen: Number(detail.totalAmount || 0),
totalAmount: formatFenToAmount(detail.totalAmount),
paidAt: detail.paidAt || null,
enriched: Boolean(detailResult?.enriched),
enrichReason: String(detailResult?.reason || '').trim(),
errorMessage: String(detailResult?.errorMessage || '').trim(),
},
items: items.map((item, index) => {
const observed = {
provider,
platform,
shopId,
shopName: resolvedShopName,
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
externalSkuCode: pickFirstNonEmpty([
item?.externalSkuCode,
item?.skuCode,
item?.externalItemId,
item?.itemId,
]),
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
latestSeenAt: null,
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
}
return {
lineId: [
platformOrderId,
index + 1,
observed.externalSkuCode || 'na',
observed.externalItemId || 'na',
].join(':'),
itemTitle: pickFirstNonEmpty([
item?.skuName,
item?.externalSkuName,
item?.externalSkuCode,
item?.externalItemId,
]),
quantity: observed.orderItemCount,
...mapAdminObservedProductItem(observed, bindings),
}
}),
}
}
@@ -1,61 +0,0 @@
export {
getAdminAgisoShopConfigs,
updateAdminAgisoShopConfigs,
} from "./agiso-service.js";
export {
getAdminNinetyoneOrders,
retryAdminNinetyoneOrder,
failAdminNinetyoneOrder,
} from "./ninetyone-service.js";
export {
getAdminKuaishouEticketSourceConfig,
updateAdminKuaishouEticketSourceConfig,
queryAdminKuaishouEticketDetail,
queryAdminKuaishouEticketShopInfo,
consumeAdminKuaishouEticket,
} from "./kuaishou-eticket-service.js";
export {
listAdminCloudtentaclesSources,
deleteAdminCloudtentaclesSource,
updateAdminCloudtentaclesSourceConfig,
sendAdminCloudtentaclesSmsCode,
testAdminCloudtentaclesLogin,
validateAdminCloudtentaclesSession,
getAdminCloudtentaclesAsset,
getAdminCloudtentaclesCategories,
getAdminCloudtentaclesSkuList,
buyAdminCloudtentaclesSku,
useAdminCloudtentaclesSku,
getAdminCloudtentaclesKnapsack,
listAdminCloudtentaclesVirtualNumbers,
appointAdminCloudtentaclesVirtualNumber,
generateAdminCloudtentaclesLoginCode,
fetchAdminCloudtentaclesVirtualNumberCode,
verifyAdminCloudtentaclesLoginCode,
getAdminCloudtentaclesBindUrl,
backAdminCloudtentaclesVirtualNumber,
runAdminCloudtentaclesFullFlow,
} from "./cloudtentacles-service.js";
export {
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminFulfillmentBindingConfigs,
} from "./fulfillment-bindings-service.js";
export {
getAdminKuaishouCloudFulfillmentConfig,
updateAdminKuaishouCloudFulfillmentConfig,
} from "./kuaishou-cloud-fulfillment-service.js";
export {
getAdminNotificationConfig,
updateAdminNotificationConfig,
testAdminNotification,
getAdminScheduledJobsConfig,
updateAdminScheduledJobsConfig,
runAdminScheduledJobNow,
} from "./notification-service.js";
@@ -1,175 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
assertAdminFulfillmentBindingsInput,
buildAdminFulfillmentBindingUniqueKey,
normalizeAdminFulfillmentBindingItem,
validateAdminFulfillmentBindingConfigs,
} from './validation.js'
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
})
test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => {
const result = normalizeAdminFulfillmentBindingItem(
{
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
shopName: ' 店铺 ',
skuCode: 'sku-1',
match: {
externalSkuCode: 'ext-1',
},
config: {
kuaishouShop: {
note: 'keep',
},
},
},
{ index: 0 },
)
assert.deepEqual(result, {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺',
skuCode: 'sku-1',
skuName: '',
profileKey: 'manual_review',
enabled: true,
priority: undefined,
config: {
kuaishouShop: {
note: 'keep',
},
},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
})
})
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
match: {
externalSkuCode: 'ext-1',
},
},
{ index: 1 },
),
/第 2 条规则缺少内部履约 SKU/,
)
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
skuCode: 'sku-1',
},
{ index: 2 },
),
/第 3 条规则至少需要一种外部匹配条件/,
)
})
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
assert.equal(
buildAdminFulfillmentBindingUniqueKey({
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-a',
skuCode: 'sku-1',
match: {
externalItemId: '',
externalSkuCode: '',
externalSkuName: ' 礼包 A ',
},
}),
'91kaquan::kuaishou::shop-a::::::礼包 a::sku-1',
)
})
test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'missing-profile',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return null
},
},
),
/第 1 条规则使用了不存在的履约方式: missing-profile/,
)
})
test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: ' ext-1 ',
},
},
{
provider: 'agiso',
platform: 'xianyu',
shopId: ' shop-1 ',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return { key: 'manual_review' }
},
},
),
/第 2 条规则与其它规则重复/,
)
})
@@ -1,125 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { normalizeProductName } from '../../order/product-match-service.js'
import { isPlainObject } from './context.js'
type JsonObject = Record<string, any>
export function assertAdminFulfillmentBindingsInput(bindings: unknown) {
if (Array.isArray(bindings)) {
return
}
throw createHttpError('履约配置格式不正确', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_payload',
})
}
export function normalizeAdminFulfillmentBindingItem(
rawBinding: unknown,
options: JsonObject = {},
) {
const index = Number(options.index || 0)
if (!isPlainObject(rawBinding)) {
throw createHttpError(`${index + 1} 条规则格式不正确`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_item',
})
}
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
const platform = String(rawBinding.platform || '').trim()
const shopId = String(rawBinding.shopId || '').trim()
const shopName = String(rawBinding.shopName || '').trim()
const skuCode = String(rawBinding.skuCode || '').trim()
const skuName = String(rawBinding.skuName || '').trim()
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
const externalSkuName = String(match.externalSkuName || '').trim()
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
if (!skuCode) {
throw createHttpError(`${index + 1} 条规则缺少内部履约 SKU`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
})
}
if (!externalItemId && !externalSkuCode && !externalSkuName) {
throw createHttpError(`${index + 1} 条规则至少需要一种外部匹配条件`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
})
}
return {
provider,
platform,
shopId,
shopName,
skuCode,
skuName,
profileKey,
enabled: rawBinding.enabled !== false,
priority: rawBinding.priority,
config,
match: {
externalSkuCode,
externalItemId,
externalSkuName,
config: isPlainObject(match.config) ? match.config : {},
},
}
}
export function buildAdminFulfillmentBindingUniqueKey(binding) {
return [
String(binding?.provider || '').trim(),
String(binding?.platform || '').trim(),
String(binding?.shopId || '').trim(),
String(binding?.match?.externalItemId || '').trim(),
String(binding?.match?.externalSkuCode || '').trim(),
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
String(binding?.skuCode || '').trim(),
].join('::')
}
export async function validateAdminFulfillmentBindingConfigs(
bindings: JsonObject[],
options: JsonObject = {},
) {
assertAdminFulfillmentBindingsInput(bindings)
const getFulfillmentProfileByKey = (
options.getFulfillmentProfileByKey || (async () => null)
) as (profileKey: string) => Promise<unknown>
const seenKeys = new Set()
const normalizedBindings: JsonObject[] = []
for (const [index, rawBinding] of bindings.entries()) {
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index })
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
if (!profile) {
throw createHttpError(`${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
})
}
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
if (seenKeys.has(uniqueKey)) {
throw createHttpError(`${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
statusCode: 409,
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
})
}
seenKeys.add(uniqueKey)
normalizedBindings.push(normalized)
}
return normalizedBindings
}
@@ -1,127 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAdminAgisoMessagingSavePayload,
buildAdminAgisoShopConfigUpdate,
normalizeAdminCloudtentaclesSourceConfigPayload,
} from './writes.js'
test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => {
assert.deepEqual(
buildAdminAgisoShopConfigUpdate(
{
shopId: ' shop-1 ',
shopName: ' ',
accessToken: ' new-token ',
messageTemplate: ' ',
autoDeliveryMessageTemplate: ' done ',
enabled: false,
},
{
shopName: '旧店铺',
accessToken: 'old-token',
messageTemplate: 'old-template',
autoDeliveryMessageTemplate: 'old-auto',
appSecret: 'keep-secret',
},
),
{
shopId: 'shop-1',
config: {
accessToken: 'new-token',
autoDeliveryMessageTemplate: 'done',
appSecret: 'keep-secret',
enabled: false,
},
},
)
})
test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => {
assert.deepEqual(
buildAdminAgisoMessagingSavePayload(
{
defaults: {
messageTemplate: ' next\\nline ',
autoDeliveryMessageTemplate: ' ',
},
shops: [
{
shopId: ' shop-1 ',
shopName: '店铺一',
accessToken: '',
enabled: true,
},
{
shopId: 'shop-2',
shopName: ' 店铺二 ',
accessToken: ' token-2 ',
messageTemplate: ' hi ',
apiVersion: ' v2 ',
},
{
shopId: '',
accessToken: 'skip',
},
],
},
{
currentDefaults: {
messageTemplate: 'old',
autoDeliveryMessageTemplate: 'old-auto',
},
currentMap: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
},
},
},
),
{
defaults: {
messageTemplate: 'next\nline',
},
shops: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
enabled: true,
shopName: '店铺一',
},
'shop-2': {
accessToken: 'token-2',
apiVersion: 'v2',
messageTemplate: 'hi',
shopName: '店铺二',
},
},
},
)
})
test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => {
assert.deepEqual(
normalizeAdminCloudtentaclesSourceConfigPayload({
enabled: false,
baseUrl: ' ',
username: ' user ',
password: ' pass ',
phone: ' 13800138000 ',
deviceId: ' ',
deviceType: '2',
}),
{
key: 'default',
label: '',
enabled: false,
baseUrl: 'https://123.207.217.176',
username: 'user',
password: 'pass',
phone: '13800138000',
deviceId: '-',
deviceType: 2,
},
)
})
@@ -1,100 +0,0 @@
import { applyOptionalStringField } from "./domain.js";
type JsonObject = Record<string, any>
export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
return null;
}
const next: JsonObject = { ...current };
const shopName = String(item?.shopName || "").trim();
const accessToken = String(item?.accessToken || "").trim();
const messageTemplate = String(item?.messageTemplate || "").trim();
const autoDeliveryMessageTemplate = String(
item?.autoDeliveryMessageTemplate || ""
).trim();
const appSecret = String(item?.appSecret || "").trim();
const apiVersion = String(item?.apiVersion || "").trim();
const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim();
if (shopName) {
next.shopName = shopName;
} else {
delete next.shopName;
}
if (accessToken) {
next.accessToken = accessToken;
}
if (messageTemplate) {
next.messageTemplate = messageTemplate;
} else if (typeof item?.messageTemplate === "string") {
delete next.messageTemplate;
}
if (autoDeliveryMessageTemplate) {
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate;
} else if (typeof item?.autoDeliveryMessageTemplate === "string") {
delete next.autoDeliveryMessageTemplate;
}
if (appSecret) {
next.appSecret = appSecret;
}
if (apiVersion) {
next.apiVersion = apiVersion;
}
if (sendMessageEndpoint) {
next.sendMessageEndpoint = sendMessageEndpoint;
}
if (typeof item?.enabled === "boolean") {
next.enabled = item.enabled;
}
if (!next.accessToken) {
return null;
}
return { shopId, config: next };
}
export function buildAdminAgisoMessagingSavePayload(
payload: JsonObject = {},
options: JsonObject = {}
) {
const rawItems = Array.isArray(payload.shops) ? payload.shops : [];
const currentDefaults: JsonObject = options.currentDefaults || {};
const currentMap: Record<string, JsonObject> = options.currentMap || {};
const nextDefaults: JsonObject = {
...currentDefaults,
};
const nextMap: Record<string, JsonObject> = {};
applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults);
applyOptionalStringField(
nextDefaults,
"autoDeliveryMessageTemplate",
payload.defaults
);
for (const item of rawItems) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
continue;
}
const updated = buildAdminAgisoShopConfigUpdate(
item,
currentMap[shopId] || {}
);
if (!updated) {
continue;
}
nextMap[updated.shopId] = updated.config;
}
return {
defaults: nextDefaults,
shops: nextMap,
};
}
@@ -1,520 +0,0 @@
import {
markInventoryItemDelivered,
releaseReservedInventoryItem,
} from '../../../repositories/inventory-repo.js'
import { updateClaimToken } from '../../../repositories/claim-token-repo.js'
import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import {
getTaskInventoryBindingById,
listTaskInventoryBindingsByTaskId,
} from '../../../repositories/task-inventory-binding-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createTaskClaimToken } from '../../claim/claim-service.js'
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../../order/inventory-service.js'
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
import { closeTencentBrowserSession } from '../../session/session.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import {
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
parseTaskContext,
} from '../admin-read-shared-helpers.js'
import {
getRequiredTask,
mapTaskActionPayload,
} from '../admin-task-read-helpers.js'
import {
ensureViewerCanOperateAssistedTask,
getTaskClaimExpiresAt,
isRecoverableTaskSessionCloseError,
maskCode,
normalizeManualDispatchOutcome,
resolveTaskInventoryGroupCodes,
} from './shared.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../../types/admin-read-inputs.js'
import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js'
import type {
AdminTaskActionResponse,
AdminTaskBindingReleaseResponse,
AdminTaskManualDispatchResponse,
} from '../../../types/admin-write-models.js'
import type { TaskRow } from '../../../types/repository-rows.js'
import type { TaskUpdatePatch } from '../../../types/repository-inputs.js'
type CloseAdminTaskDeps = {
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
nowIso?: () => string
}
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
if (!primaryInventoryItemId) {
throw createHttpError('当前任务没有预占库存项', {
statusCode: 409,
errorCode: 'admin_task_no_reserved_inventory',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存项', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(primaryInventoryItemId, now)
const updatedTask = await updateTask(task.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '已手动释放预占库存项',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function releaseAdminTaskInventoryBinding(
taskId: AdminEntityIdInput,
bindingId: AdminEntityIdInput,
): Promise<AdminTaskBindingReleaseResponse> {
const task = await getRequiredTask(taskId)
const binding = await getTaskInventoryBindingById(Number(bindingId))
if (!binding || Number(binding.task_id) !== Number(task.id)) {
throw createHttpError('任务库存绑定不存在', {
statusCode: 404,
errorCode: 'admin_task_inventory_binding_not_found',
})
}
if (String(binding.binding_status || '').trim() !== 'reserved') {
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
statusCode: 409,
errorCode: 'admin_task_inventory_binding_release_not_allowed',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存绑定', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(binding.inventory_item_id, now)
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
? 'waiting_inventory'
: task.task_status
if (!hasReservedBindings) {
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
updated_at: now,
})
await createTaskEvent(task.id, 'inventory_binding_released', {
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
roleKey: String(binding.role_key || '').trim(),
}, now)
return {
task: mapTaskActionPayload(updatedTask),
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
}
}
export async function regenerateAdminTaskClaimLink(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
const viewerContext = createAdminViewerContext(session)
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重新生成领取链接', {
statusCode: 409,
errorCode: 'admin_task_regenerate_not_allowed',
})
}
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
statusCode: 403,
errorCode: 'admin_task_regenerate_permission_denied',
})
}
const now = nowIso()
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
const claimToken = await createTaskClaimToken(task.id)
const updatedTask = await updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
task_status: 'link_generated',
last_error: '',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimToken.claimUrl,
token: claimToken.token,
}
}
export async function confirmAdminTaskAssistedRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
await confirmClaimRoleForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function redeemAdminTaskAssisted(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
await redeemClaimTaskForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function closeAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
return closeAdminTaskWithDeps(taskId)
}
export async function closeAdminTaskWithDeps(
taskId: AdminEntityIdInput,
{
getRequiredTask: getTask = getRequiredTask,
listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId,
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
updateClaimToken: updateToken = updateClaimToken,
updateTask: updateTaskRecord = updateTask,
createTaskEvent: createEvent = createTaskEvent,
closeTencentBrowserSession: closeSession = closeTencentBrowserSession,
nowIso: getNowIso = nowIso,
}: CloseAdminTaskDeps = {},
): Promise<AdminTaskActionResponse> {
const task = await getTask(taskId)
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能关闭', {
statusCode: 409,
errorCode: 'admin_task_close_not_allowed',
})
}
const now = getNowIso()
const bindings = await listBindings(task.id)
const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved')
const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0)))
const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed')
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
let browserSessionClosed = false
if (primaryClaimTokenId) {
await updateToken(primaryClaimTokenId, {
status: 'revoked',
expired_at: now,
updated_at: now,
})
}
if (task.browser_session_id) {
try {
await closeSession(task.browser_session_id)
browserSessionClosed = true
} catch (error) {
if (!isRecoverableTaskSessionCloseError(error)) {
throw error
}
}
}
for (const inventoryItemId of releasedInventoryItemIds) {
await releaseReserved(inventoryItemId, now)
}
const closeReasonParts = ['已手动关闭任务']
if (primaryClaimTokenId) {
closeReasonParts.push('领取链接已失效')
}
if (releasedInventoryItemIds.length > 0) {
closeReasonParts.push('预占库存已释放')
}
const updatedTask = await updateTaskRecord(task.id, {
task_status: 'closed',
inventory_status: hasConsumedBindings ? 'consumed' : 'pending',
delivery_status: 'closed',
user_action_status: 'closed',
claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at,
browser_session_id: '',
last_error: task.last_error || closeReasonParts.join(''),
updated_at: now,
})
await createEvent(task.id, 'task_closed', {
claimTokenRevoked: Boolean(primaryClaimTokenId),
releasedInventoryItemIds,
releasedInventoryCount: releasedInventoryItemIds.length,
browserSessionClosed,
}, now)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const updatedTask = await updateTask(task.id, {
task_status: 'manual_review',
delivery_status: task.delivery_status || 'pending',
last_error: task.last_error || '已转人工处理',
updated_at: nowIso(),
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function retryAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const now = nowIso()
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
})
}
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重试', {
statusCode: 409,
errorCode: 'admin_task_retry_not_allowed',
})
}
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const orderItems = await listOrderItemsByOrderId(task.order_id)
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
let claimTokenId = getTaskPrimaryClaimTokenId(task)
let nextStatus = 'link_generated'
let lastError = ''
let claimExpiresAt = getTaskClaimExpiresAt(task)
let claimUrl = ''
let token = ''
if (!reservedInventoryItemId) {
const reserved = await reserveInventoryForTask({
skuCode: orderItem?.sku_code || '',
taskId: task.id,
credentialType: primaryRequirement?.credentialType || 'tencent_code',
roleKey: primaryRequirement?.roleKey || 'primary_code',
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
})
if (!reserved) {
nextStatus = 'waiting_inventory'
lastError = '库存不足,等待可用库存凭据'
} else {
reservedInventoryItemId = reserved.id
}
}
if (nextStatus === 'link_generated' && !claimTokenId) {
const claimToken = await createTaskClaimToken(task.id)
claimTokenId = claimToken.id
claimExpiresAt = claimToken.expired_at
claimUrl = claimToken.claimUrl
token = claimToken.token
}
const updatedTask = await updateTask(task.id, {
task_status: nextStatus,
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
claim_token: token || task.claim_token || '',
claim_expires_at: claimExpiresAt,
last_error: lastError,
updated_at: now,
})
const response: AdminTaskActionResponse = {
task: mapTaskActionPayload(updatedTask),
}
if (claimUrl) {
response.claimUrl = claimUrl
response.token = token
}
return response
}
export async function completeAdminTaskManualDispatch(
taskId: AdminEntityIdInput,
payload: AdminTaskManualDispatchInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskManualDispatchResponse> {
const task = await getRequiredTask(taskId)
if (!isManualDispatchTask(task)) {
throw createHttpError('当前任务不是人工履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_manual_dispatch',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_already_completed',
})
}
const now = nowIso()
const outcome = normalizeManualDispatchOutcome(payload.outcome)
const resultMessage = String(payload.resultMessage || '').trim()
const deliveryReference = String(payload.deliveryReference || '').trim()
const deliveredCredential = String(payload.deliveredCredential || '').trim()
const context = parseTaskContext(task)
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
? 'consumed'
: task.inventory_status || 'not_required'
const manualDispatch = {
outcome,
deliveryReference,
deliveredCredential,
resultMessage: resultMessage || fallbackMessage,
completedAt: now,
completedBy: session
? {
userId: Number(session.userId || 0),
username: String(session.username || ''),
role: String(session.role || ''),
}
: null,
}
if (outcome === 'delivered' && inventoryItemId) {
await markInventoryItemDelivered(inventoryItemId, now)
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
delivery_status: nextDeliveryStatus,
result_code: resultCode,
result_message: resultMessage || fallbackMessage,
user_action_status: 'not_required',
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
context_json: JSON.stringify({
...context,
manualDispatch,
}),
updated_at: now,
})
await createTaskEvent(task.id, 'manual_dispatch_completed', {
outcome,
resultCode,
resultMessage: resultMessage || fallbackMessage,
deliveryReference,
deliveredCredentialMasked: maskCode(deliveredCredential),
completedBy: manualDispatch.completedBy,
}, now)
const taskAfterAutoDelivery = outcome === 'delivered'
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order: await getOrderById(task.order_id),
task: updatedTask,
trigger: 'manual_dispatch_completed',
})).task || updatedTask
: updatedTask
return {
outcome,
task: mapTaskActionPayload(taskAfterAutoDelivery),
}
}
@@ -1,32 +0,0 @@
import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js'
import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js'
import { createHttpError } from '../../../utils/http.js'
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js'
export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise<AdminWebhookReplayResponse> {
const event = await getWebhookEventById(Number(eventId))
if (!event) {
throw createHttpError('Webhook 事件不存在', {
statusCode: 404,
errorCode: 'admin_webhook_not_found',
})
}
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
throw createHttpError('当前只支持重放 agiso webhook', {
statusCode: 409,
errorCode: 'admin_webhook_replay_not_supported',
})
}
const result = await replayAgisoTradeWebhookEvent(event)
return {
eventId: event.id,
replayed: true,
result,
}
}
@@ -1,26 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js'
test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => {
assert.deepEqual(
resolveBindingMatchShopIds({
provider: '91kaquan',
platform: 'kuaishou',
shopId: '91kaquan',
}),
['91kaquan'],
)
})
test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => {
assert.deepEqual(
resolveBindingMatchShopIds({
provider: 'agiso',
platform: 'xianyu',
shopId: '',
}),
[''],
)
})
@@ -8,7 +8,6 @@ import {
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
import { normalizeProductName } from '../order/product-match-service.js'
import { query } from '../../db/client.js'
import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
import {
getKuaishouCloudFulfillmentConfig,
mapKuaishouCloudFulfillmentItemsToBindings,
@@ -108,10 +107,7 @@ async function ensureCoreProfiles() {
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
const timestamp = nowIso()
const bindingsToApply = [
...getLegacyOrderFulfillmentBindingConfigs(),
...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()),
]
const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
await query('DELETE FROM product_match_rules')
await query('DELETE FROM sku_fulfillment_bindings')
@@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
const bindingConfig = resolveBindingRuntimeConfig(binding)
const matchShopIds = resolveBindingMatchShopIds(binding)
const match = binding.match || {}
const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
const externalSkuName = String(match.externalSkuName || '').trim()
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
@@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
}
}
export function resolveBindingMatchShopIds(binding: JsonObject = {}) {
function resolveBindingMatchShopIds(binding: JsonObject = {}) {
return [String(binding.shopId || '').trim()]
}
@@ -1,273 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js'
test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => {
const events = []
const invalidated = []
const reserveCalls = []
const reloadCalls = []
const context = {
task: {
id: 42,
browser_session_id: 'txbs-test',
},
orderItem: {
sku_code: 'df-cdk',
},
}
const initialInventoryItem = {
id: 1,
display_value: 'DJQFf7HgONCL4XD4EH',
credential_type: 'tencent_code',
inventory_group_code: 'A组',
}
const replacementInventoryItem = {
id: 2,
display_value: 'ABCD1234EFGH5678',
credential_type: 'tencent_code',
inventory_group_code: 'A组',
}
let redeemCount = 0
const result = await redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
reloadTencentBrowserSession: async (sessionId) => {
reloadCalls.push(sessionId)
return { sessionId, status: 'ready_to_redeem' }
},
redeemTencentBrowserSession: async (sessionId, payload) => {
redeemCount += 1
if (redeemCount === 1) {
assert.equal(sessionId, 'txbs-test')
assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH')
return {
redeem: {
final: {
redeem: {
iRet: -183,
sMsg: '该CDKEY不存在,请您确认后输入!',
},
},
},
}
}
assert.equal(payload.code, 'ABCD1234EFGH5678')
return {
redeem: {
final: {
redeem: {
iRet: 0,
sMsg: '领取成功',
},
},
},
}
},
invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => {
invalidated.push({ inventoryItemId, reason, updatedAt })
return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt }
},
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return replacementInventoryItem
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
},
nowIso: (() => {
const values = [
'2026-04-14T12:03:34.000Z',
'2026-04-14T12:03:35.000Z',
]
let index = 0
return () => values[index++] || values[values.length - 1]
})(),
},
)
assert.equal(result.inventoryItem.id, 2)
assert.equal(result.classification.outcome, 'success')
assert.equal(result.attempts.length, 2)
assert.deepEqual(
result.attempts.map((attempt) => ({
attempt: attempt.attempt,
inventoryItemId: attempt.inventoryItemId,
outcome: attempt.outcome,
resultCode: attempt.resultCode,
})),
[
{
attempt: 1,
inventoryItemId: 1,
outcome: 'code_invalid',
resultCode: '-183',
},
{
attempt: 2,
inventoryItemId: 2,
outcome: 'success',
resultCode: '0',
},
],
)
assert.deepEqual(invalidated, [
{
inventoryItemId: 1,
reason: '该CDKEY不存在,请您确认后输入!',
updatedAt: '2026-04-14T12:03:34.000Z',
},
])
assert.deepEqual(reserveCalls, [
{
skuCode: 'df-cdk',
taskId: 42,
credentialType: 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: ['A组'],
},
])
assert.deepEqual(reloadCalls, ['txbs-test'])
assert.deepEqual(
events.map((event) => ({
eventType: event.eventType,
taskId: event.taskId,
createdAt: event.createdAt,
payload: event.payload,
})),
[
{
eventType: 'claim_redeem_code_invalid',
taskId: 42,
createdAt: '2026-04-14T12:03:34.000Z',
payload: {
inventoryItemId: 1,
codeMasked: 'DJQF****D4EH',
resultCode: '-183',
resultMessage: '该CDKEY不存在,请您确认后输入!',
},
},
{
eventType: 'claim_redeem_inventory_replaced',
taskId: 42,
createdAt: '2026-04-14T12:03:35.000Z',
payload: {
previousInventoryItemId: 1,
previousCodeMasked: 'DJQF****D4EH',
previousOutcome: 'code_invalid',
nextInventoryItemId: 2,
nextCodeMasked: 'ABCD****5678',
credentialType: 'tencent_code',
inventoryGroupCode: 'A组',
},
},
],
)
})
test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => {
const events = []
const consumed = []
const reserveCalls = []
const context = {
task: {
id: 57,
browser_session_id: 'txbs-used',
},
orderItem: {
sku_code: 'df-cdk-used',
},
}
const initialInventoryItem = {
id: 7,
display_value: 'USED1234CODE5678',
credential_type: 'tencent_code',
inventory_group_code: 'B组',
}
let thrown = null
try {
await redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
redeemTencentBrowserSession: async () => ({
redeem: {
final: {
redeem: {
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
},
},
},
}),
markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => {
consumed.push({ inventoryItemId, reason, updatedAt })
return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt }
},
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return null
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
},
nowIso: () => '2026-04-14T12:05:00.000Z',
},
)
} catch (error) {
thrown = error
}
assert.ok(thrown)
assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted')
assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory')
assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending')
assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending')
assert.equal(thrown.redeemTaskState.attempts.length, 1)
assert.deepEqual(consumed, [
{
inventoryItemId: 7,
reason: '兑换码已使用。',
updatedAt: '2026-04-14T12:05:00.000Z',
},
])
assert.deepEqual(reserveCalls, [
{
skuCode: 'df-cdk-used',
taskId: 57,
credentialType: 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: ['B组'],
},
])
assert.deepEqual(
events.map((event) => ({
taskId: event.taskId,
eventType: event.eventType,
payload: event.payload,
createdAt: event.createdAt,
})),
[
{
taskId: 57,
eventType: 'claim_redeem_code_used',
payload: {
inventoryItemId: 7,
codeMasked: 'USED****5678',
resultCode: '',
resultMessage: '兑换码已使用。',
},
createdAt: '2026-04-14T12:05:00.000Z',
},
],
)
})
@@ -1,28 +0,0 @@
export {
getClaimDetail,
getClaimDetailForAdminTask,
getClaimSessionSummary,
getClaimSessionSummaryForAdminTask,
confirmClaimRole,
confirmClaimRoleForAdminTask,
redeemClaimTask,
redeemClaimTaskForAdminTask,
getClaimScreenshotPath,
} from './session/actions.js'
export {
createClaimSession,
createClaimSessionForAdminTask,
reloadClaimSession,
reloadClaimSessionForAdminTask,
closeClaimSession,
closeClaimSessionForAdminTask,
} from './session/lifecycle.js'
export {
getClaimContext,
} from './session/context.js'
export {
redeemClaimTaskWithInventoryFallbackWithDeps,
} from './session/redeem.js'
@@ -1,32 +1,95 @@
import { buildClaimUrl } from '../claim-service.js'
import { createHttpError } from '../../../utils/http.js'
import { maskCode as maskCodeValue } from '../../../utils/masking.js'
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js'
import {
parseTaskContext as parseTaskContextValue,
parseTaskState as parseTaskStateValue,
} from '../../../utils/task-json.js'
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { getOrderById } from '../../repositories/order-repo.js'
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
import { createHttpError } from '../../utils/http.js'
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import { buildClaimUrl } from './claim-service.js'
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
export const REDEEM_REPLACEMENT_LIMIT = 10
export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) {
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
const finalRedeem = session?.redeem?.final?.redeem || null
export async function getClaimContext(token: unknown) {
const normalized = String(token || '').trim()
if (!normalized) {
throw createHttpError('缺少领取 token', {
statusCode: 400,
errorCode: 'missing_claim_token',
})
}
const claimToken = await findClaimTokenByToken(normalized)
if (!claimToken) {
throw createHttpError('领取链接无效或不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const task = await findTaskByClaimTokenId(claimToken.id)
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
if (claimToken.status !== 'active') {
throw createHttpError('领取链接当前不可用', {
statusCode: 410,
errorCode: 'claim_token_inactive',
})
}
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
const expiredContext = await expireClaimContext(claimToken, task)
throw createHttpError('领取链接已过期', {
statusCode: 410,
errorCode: 'claim_token_expired',
context: expiredContext,
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) {
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim',
flowType: 'kuaishou_cloud',
task: {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
executorKey: task.executor_key || '',
requiresSupportReview: isAssistedClaimTask(task),
requiresSupportReview: false,
expiresAt: claimToken.expired_at,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
@@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se
skuName: orderItem.sku_name,
quantity: orderItem.quantity,
},
session,
session: null,
kuaishouCloudFulfillment,
result: task.redeemed_at || session?.status === 'redeemed'
result: task.redeemed_at
? {
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
screenshotReady,
screenshotUrl,
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
@@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
}
}
export function assertTaskCanProceed(task) {
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
throw createHttpError('当前任务已经结束,不能继续操作', {
statusCode: 410,
errorCode: 'claim_task_closed',
})
}
}
export function assertPublicClaimActionAllowed(task, action) {
if (!isAssistedClaimTask(task)) {
return
}
const message = action === 'confirm'
? '当前商品需要客服复核角色,请登录后联系人工继续'
: '当前商品需要客服确认后再执行兑换,请联系人工继续'
throw createHttpError(message, {
statusCode: 409,
errorCode: 'claim_support_review_required',
})
}
export function mergeTaskContext(task, patch = {}) {
return {
...parseTaskContext(task),
...patch,
}
}
export function parseTaskState(task) {
return parseTaskStateValue(task)
}
export function isAssistedClaimTask(task) {
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
}
export function parseTaskContext(task) {
function parseTaskContext(task) {
return parseTaskContextValue(task)
}
export function normalizeClaimLoginType(loginType) {
return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq'
}
async function expireClaimContext(claimToken, task) {
const now = nowIso()
const nextClaimToken = await updateClaimToken(claimToken.id, {
status: 'expired',
updated_at: now,
})
export function isRecoverableSessionError(error) {
const errorCode = String(error?.errorCode || error?.code || '').trim()
return errorCode === 'session_not_found' || errorCode === 'session_closed'
}
let nextTask = task
export function maskCode(value) {
return maskCodeValue(value, { shortMask: '****' })
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
nextTask = await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
}
return {
claimToken: nextClaimToken,
task: nextTask,
}
}
@@ -18,8 +18,7 @@ import {
prepareKuaishouCloudFulfillmentTask,
refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud-task-service.js'
import { buildClaimDetailPayload } from './session/shared.js'
import { getClaimContext } from './session/context.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
@@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
@@ -1,312 +0,0 @@
import {
getTencentBrowserSession,
getTencentBrowserSessionScreenshotPath,
} from '../../session/session.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import {
getInventoryItemById,
markInventoryItemDelivered,
} from '../../../repositories/inventory-repo.js'
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js'
import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { getClaimContext, getClaimContextByTaskId } from './context.js'
import { loadTaskSession, syncTaskWithSession } from './runtime.js'
import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js'
import {
assertPublicClaimActionAllowed,
assertTaskCanProceed,
buildClaimDetailPayload,
maskCode,
mergeTaskContext,
} from './shared.js'
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
const context = await getClaimContext(token)
let { task, session } = await loadTaskSession(context.task, { includeQrImage })
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
task = await syncKuaishouCloudRoleInfo(task)
}
const syncedTask = session ? await syncTaskWithSession(task, session) : task
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) {
const context = await getClaimContextByTaskId(taskId)
const { task, session } = await loadTaskSession(context.task, { includeQrImage })
const syncedTask = session ? await syncTaskWithSession(task, session) : task
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
export async function getClaimSessionSummary(token) {
return getClaimSessionSummaryByContextLoader(() => getClaimContext(token))
}
export async function getClaimSessionSummaryForAdminTask(taskId) {
return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId))
}
export async function confirmClaimRole(token) {
const context = await getClaimContext(token)
assertPublicClaimActionAllowed(context.task, 'confirm')
return finalizeClaimRoleConfirmation(context)
}
export async function confirmClaimRoleForAdminTask(taskId) {
const context = await getClaimContextByTaskId(taskId)
return finalizeClaimRoleConfirmation(context)
}
export async function redeemClaimTask(token) {
const context = await getClaimContext(token)
assertTaskCanProceed(context.task)
assertPublicClaimActionAllowed(context.task, 'redeem')
return finalizeClaimTaskRedeem(context)
}
export async function redeemClaimTaskForAdminTask(taskId) {
const context = await getClaimContextByTaskId(taskId)
assertTaskCanProceed(context.task)
return finalizeClaimTaskRedeem(context)
}
export async function getClaimScreenshotPath(token) {
const context = await getClaimContext(token)
if (context.task.screenshot_path) {
return context.task.screenshot_path
}
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有兑换截图', {
statusCode: 404,
errorCode: 'claim_screenshot_not_ready',
})
}
return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id)
}
async function getClaimSessionSummaryByContextLoader(loadContext) {
const context = await loadContext()
const { task, session } = await loadTaskSession(context.task, {
includeQrImage: false,
})
if (!session) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
const syncedTask = await syncTaskWithSession(task, session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function finalizeClaimRoleConfirmation(context) {
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有创建浏览器会话', {
statusCode: 409,
errorCode: 'claim_session_not_created',
})
}
const session = await getTencentBrowserSession(context.task.browser_session_id)
const activityInfo = session.activityInfo || null
if (!activityInfo?.role?.ready) {
throw createHttpError('当前角色信息还没有准备好', {
statusCode: 409,
errorCode: 'claim_role_not_ready',
})
}
const updatedTask = await updateTask(context.task.id, {
task_status: 'role_confirmed',
nickname: String(activityInfo.nickname || ''),
role_id: String(activityInfo.role.roleId || ''),
role_name: String(activityInfo.role.roleName || ''),
area: String(activityInfo.role.area || ''),
partition_name: String(activityInfo.role.partition || ''),
user_action_status: 'role_confirmed',
role_confirmed_at: nowIso(),
updated_at: nowIso(),
last_error: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function finalizeClaimTaskRedeem(context) {
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有创建浏览器会话', {
statusCode: 409,
errorCode: 'claim_session_not_created',
})
}
if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') {
throw createHttpError('当前任务还未确认角色,不能开始兑换', {
statusCode: 409,
errorCode: 'claim_role_not_confirmed',
})
}
const inventoryItem = context.task.primary_inventory_item_id
? await getInventoryItemById(context.task.primary_inventory_item_id)
: null
if (
!inventoryItem ||
String(inventoryItem.status || '').trim() !== 'reserved' ||
!String(inventoryItem.display_value || '').trim()
) {
throw createHttpError('当前任务没有可用的预占库存凭据', {
statusCode: 409,
errorCode: 'claim_inventory_not_reserved',
})
}
await updateTask(context.task.id, {
task_status: 'redeeming',
delivery_status: 'processing',
updated_at: nowIso(),
last_error: '',
})
try {
const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem)
const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult
const finalRedeem = session.redeem?.final?.redeem || null
const finishedAt = nowIso()
const updatedTask = await updateTask(context.task.id, {
task_status: 'redeemed',
inventory_status: 'consumed',
delivery_status: 'delivered',
result_code: resolveTencentRedeemResultCode(finalRedeem, classification),
result_message: classification.message,
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
artifacts_json: JSON.stringify(session.artifacts || {}),
context_json: JSON.stringify(mergeTaskContext(context.task, {
redeemResolution: {
status: 'success',
attempts,
replacementCount: Math.max(0, attempts.length - 1),
finishedAt,
},
})),
redeemed_at: finishedAt,
updated_at: finishedAt,
last_error: '',
})
await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt)
await createTaskEvent(context.task.id, 'claim_redeem_completed', {
inventoryItemId: deliveredInventoryItem.id,
codeMasked: maskCode(deliveredInventoryItem.display_value),
replacementCount: Math.max(0, attempts.length - 1),
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
resultMessage: classification.message,
}, finishedAt)
const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order: context.order,
task: updatedTask,
trigger: 'claim_redeemed',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: autoDeliveryResult.task || updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
} catch (error) {
const now = nowIso()
const nextRetryCount = Number(context.task.attempt_count || 0) + 1
const failureState = (
error && typeof error === 'object'
? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState
: null
) || {
taskStatus: 'retry_pending',
inventoryStatus: inventoryItem ? 'reserved' : 'pending',
deliveryStatus: 'pending',
lastError: error instanceof Error ? error.message : String(error || ''),
attempts: [],
classification: null,
}
const failedTask = await updateTask(context.task.id, {
task_status: failureState.taskStatus,
inventory_status: failureState.inventoryStatus,
delivery_status: failureState.deliveryStatus,
result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '',
result_message: String(failureState.lastError || ''),
attempt_count: nextRetryCount,
last_error: String(failureState.lastError || ''),
context_json: JSON.stringify(mergeTaskContext(context.task, {
redeemResolution: {
status: 'failed',
taskStatus: failureState.taskStatus,
attempts: failureState.attempts || [],
replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1),
finishedAt: now,
},
})),
updated_at: now,
})
if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) {
await notifyClaimRedeemNeedsAttention({
task: failedTask,
order: context.order,
status: failureState.taskStatus,
errorMessage: failureState.lastError,
})
}
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
task: failedTask,
})
}
}
@@ -1,150 +0,0 @@
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import { getOrderItemById } from '../../../repositories/order-item-repo.js'
import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js'
import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { CLAIM_TERMINAL_STATUSES } from './shared.js'
export async function getClaimContext(token) {
const normalized = String(token || '').trim()
if (!normalized) {
throw createHttpError('缺少领取 token', {
statusCode: 400,
errorCode: 'missing_claim_token',
})
}
const claimToken = await findClaimTokenByToken(normalized)
if (!claimToken) {
throw createHttpError('领取链接无效或不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const task = await findTaskByClaimTokenId(claimToken.id)
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
if (claimToken.status !== 'active') {
throw createHttpError('领取链接当前不可用', {
statusCode: 410,
errorCode: 'claim_token_inactive',
})
}
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
const expiredContext = await expireClaimContext(claimToken, task)
throw createHttpError('领取链接已过期', {
statusCode: 410,
errorCode: 'claim_token_expired',
context: expiredContext,
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
export async function getClaimContextByTaskId(taskId) {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
const claimTokenId = Number(task.primary_claim_token_id || 0)
if (!claimTokenId) {
throw createHttpError('当前任务还没有领取链接', {
statusCode: 409,
errorCode: 'claim_token_missing',
})
}
const claimToken = await getClaimTokenById(claimTokenId)
if (!claimToken) {
throw createHttpError('领取链接不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
async function expireClaimContext(claimToken, task) {
const now = nowIso()
const nextClaimToken = await updateClaimToken(claimToken.id, {
status: 'expired',
updated_at: now,
})
let nextTask = task
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
nextTask = await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
}
return {
claimToken: nextClaimToken,
task: nextTask,
}
}
@@ -1,175 +0,0 @@
import {
closeTencentBrowserSession,
createTencentBrowserSession,
reloadTencentBrowserSession,
} from '../../session/session.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { nowIso } from '../../../utils/time.js'
import { getClaimContext, getClaimContextByTaskId } from './context.js'
import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js'
import {
assertTaskCanProceed,
buildClaimDetailPayload,
isRecoverableSessionError,
normalizeClaimLoginType,
} from './shared.js'
type JsonObject = Record<string, any>
export async function createClaimSession(token: unknown, payload: JsonObject = {}) {
return createClaimSessionWithContextLoader(
() => getClaimContext(token),
payload,
)
}
export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) {
return createClaimSessionWithContextLoader(
() => getClaimContextByTaskId(taskId),
payload,
)
}
export async function reloadClaimSession(token: unknown) {
return reloadClaimSessionWithContextLoader(() => getClaimContext(token))
}
export async function reloadClaimSessionForAdminTask(taskId: unknown) {
return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
}
export async function closeClaimSession(token: unknown) {
return closeClaimSessionWithContextLoader(() => getClaimContext(token))
}
export async function closeClaimSessionForAdminTask(taskId: unknown) {
return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
}
async function createClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>, payload: JsonObject = {}) {
const context = await loadContext()
assertTaskCanProceed(context.task)
const requestedLoginType = normalizeClaimLoginType(payload.loginType)
const forceRecreate = Boolean(payload.forceRecreate)
let task = context.task
if (task.browser_session_id) {
const existing = await loadTaskSession(task, { includeQrImage: true })
task = existing.task
if (existing.session) {
const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type)
if (!forceRecreate && existingLoginType === requestedLoginType) {
const syncedTask = await syncTaskWithSession(task, existing.session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session: existing.session,
})
}
try {
await closeTencentBrowserSession(existing.session.sessionId)
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
}
task = await clearTaskSession(task, {
lastError: '',
})
}
}
const session = await createTencentBrowserSession({
loginType: requestedLoginType,
})
const updatedTask = await updateTask(task.id, {
task_status: 'claimed',
browser_session_id: session.sessionId,
login_type: session.loginType,
user_action_status: 'claimed',
claimed_at: task.claimed_at || nowIso(),
updated_at: nowIso(),
last_error: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function reloadClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
const context = await loadContext()
assertTaskCanProceed(context.task)
const active = await loadTaskSession(context.task, {
includeQrImage: true,
})
if (!active.session) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: active.task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
const session = await reloadTencentBrowserSession(active.session.sessionId)
const syncedTask = await syncTaskWithSession(active.task, session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function closeClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
const context = await loadContext()
assertTaskCanProceed(context.task)
let task = context.task
if (!task.browser_session_id) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
try {
await closeTencentBrowserSession(task.browser_session_id)
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
}
task = await clearTaskSession(task, {
lastError: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
@@ -1,301 +0,0 @@
import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js'
import { classifyTencentRedeemResult } from '../../session/session-redeem.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import {
invalidateReservedInventoryItem,
markInventoryItemConsumed,
} from '../../../repositories/inventory-repo.js'
import { reserveInventoryForTask } from '../../order/inventory-service.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js'
import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js'
type ClaimRedeemContext = {
task: Pick<TaskRow, 'id' | 'browser_session_id'>
orderItem: Pick<OrderItemRow, 'sku_code'>
}
type TencentRedeemPayload = {
[key: string]: unknown
iRet?: unknown
ret?: unknown
}
type TencentRedeemSession = {
[key: string]: unknown
sessionId?: string
artifacts?: {
[key: string]: unknown
hasScreenshot?: boolean
} | null
redeem?: {
final?: {
redeem?: TencentRedeemPayload | null
} | null
} | null
}
type TencentRedeemClassification = {
success: boolean
outcome: string
message: string
retCode?: unknown
[key: string]: unknown
}
type RedeemAttemptSummary = {
attempt: number
inventoryItemId: number
codeMasked: string
credentialType: string
outcome: string
resultCode: string
resultMessage: string
}
type RedeemTaskState = {
taskStatus: string
inventoryStatus: string
deliveryStatus: string
lastError: string
attempts: RedeemAttemptSummary[]
classification: TencentRedeemClassification | null
}
type RedeemTaskStateError = Error & {
statusCode?: number
errorCode?: string
redeemTaskState: RedeemTaskState
}
type RedeemTaskStateErrorPayload = {
statusCode?: number
errorCode?: string
taskStatus?: string
inventoryStatus?: string
deliveryStatus?: string
attempts?: RedeemAttemptSummary[]
classification?: TencentRedeemClassification | null
}
type RedeemClaimTaskDeps = {
reloadTencentBrowserSession?: (sessionId: string) => Promise<unknown>
redeemTencentBrowserSession?: (
sessionId: string,
payload: { code: string },
) => Promise<TencentRedeemSession>
classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification
markInventoryItemConsumed?: (
inventoryItemId: number | string,
reason: string,
consumedAt: string,
) => Promise<InventoryItemRow | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
invalidateReservedInventoryItem?: (
inventoryItemId: number | string,
reason: string,
updatedAt: string,
) => Promise<InventoryItemRow | null>
reserveInventoryForTask?: (payload: {
skuCode: string
taskId: number | string
credentialType?: string
roleKey?: string
inventoryGroupCodes?: string[] | null
}) => Promise<InventoryItemRow | null>
nowIso?: () => string
redeemReplacementLimit?: number
}
type RedeemClaimTaskResult = {
session: TencentRedeemSession
inventoryItem: InventoryItemRow
classification: TencentRedeemClassification
attempts: RedeemAttemptSummary[]
}
export async function redeemClaimTaskWithInventoryFallback(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
): Promise<RedeemClaimTaskResult> {
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
}
export async function redeemClaimTaskWithInventoryFallbackWithDeps(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
{
reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession,
redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession,
classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult,
markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed,
createTaskEvent: createRedeemTaskEvent = createTaskEvent,
invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem,
reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask,
nowIso: getNowIso = nowIso,
redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT,
}: RedeemClaimTaskDeps = {},
): Promise<RedeemClaimTaskResult> {
const attempts: RedeemAttemptSummary[] = []
let currentInventoryItem = initialInventoryItem
let shouldReloadBeforeNextAttempt = false
for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) {
if (shouldReloadBeforeNextAttempt) {
await reloadRedeemSession(context.task.browser_session_id)
shouldReloadBeforeNextAttempt = false
}
const session = await redeemSession(context.task.browser_session_id, {
code: currentInventoryItem.display_value,
})
const finalRedeem = session.redeem?.final?.redeem || null
const classification = classifyRedeemResult(finalRedeem)
const attemptSummary = {
attempt: attemptIndex,
inventoryItemId: currentInventoryItem.id,
codeMasked: maskCode(currentInventoryItem.display_value),
credentialType: String(currentInventoryItem.credential_type || ''),
outcome: classification.outcome,
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
resultMessage: classification.message,
}
attempts.push(attemptSummary)
if (classification.success) {
return {
session,
inventoryItem: currentInventoryItem,
classification,
attempts,
}
}
if (classification.outcome === 'code_used') {
const updatedAt = getNowIso()
await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else if (classification.outcome === 'code_invalid') {
const updatedAt = getNowIso()
await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else {
throw createRedeemTaskStateError(classification.message, {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'reserved',
deliveryStatus: 'pending',
attempts,
classification,
})
}
if (attemptIndex >= redeemReplacementLimit) {
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
errorCode: 'claim_redeem_replacement_limit_reached',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacement = await reserveReplacementInventory({
skuCode: context.orderItem.sku_code,
taskId: context.task.id,
credentialType: currentInventoryItem.credential_type || 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: currentInventoryItem.inventory_group_code
? [String(currentInventoryItem.inventory_group_code).trim()]
: null,
})
if (!replacement || !String(replacement.display_value || '').trim()) {
const exhaustedMessage = classification.outcome === 'code_used'
? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试'
: '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试'
throw createRedeemTaskStateError(exhaustedMessage, {
errorCode: 'claim_inventory_replacement_exhausted',
taskStatus: 'waiting_inventory',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacedAt = getNowIso()
await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
previousInventoryItemId: currentInventoryItem.id,
previousCodeMasked: attemptSummary.codeMasked,
previousOutcome: classification.outcome,
nextInventoryItemId: replacement.id,
nextCodeMasked: maskCode(replacement.display_value),
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(),
}, replacedAt)
currentInventoryItem = replacement
shouldReloadBeforeNextAttempt = true
}
throw createRedeemTaskStateError('兑换失败,请稍后重试', {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification: null,
})
}
function createRedeemTaskStateError(
message: string,
payload: RedeemTaskStateErrorPayload = {},
): RedeemTaskStateError {
const error = createHttpError(message, {
statusCode: payload.statusCode || 409,
errorCode: payload.errorCode || 'claim_redeem_failed',
}) as unknown as RedeemTaskStateError
error.redeemTaskState = {
taskStatus: payload.taskStatus || 'retry_pending',
inventoryStatus: payload.inventoryStatus || 'pending',
deliveryStatus: payload.deliveryStatus || 'pending',
lastError: String(message || ''),
attempts: payload.attempts || [],
classification: payload.classification || null,
}
return error
}
export function resolveTencentRedeemResultCode(
finalRedeem: TencentRedeemPayload | null,
classification: Pick<TencentRedeemClassification, 'retCode'> | null,
): string {
if (classification?.retCode != null) {
return String(classification.retCode)
}
const value = finalRedeem?.iRet ?? finalRedeem?.ret
return value == null ? '' : String(value)
}
@@ -1,107 +0,0 @@
import {
getTencentBrowserSession,
getTencentBrowserSessionSummary,
} from '../../session/session.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { nowIso } from '../../../utils/time.js'
import { isRecoverableSessionError, parseTaskState } from './shared.js'
type JsonObject = Record<string, any>
export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) {
if (!task.browser_session_id) {
return {
task,
session: null,
}
}
try {
const session = includeQrImage
? await getTencentBrowserSession(task.browser_session_id)
: await getTencentBrowserSessionSummary(task.browser_session_id)
return {
task,
session,
}
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
const nextTask = await clearTaskSession(task, {
lastError: '浏览器会话已失效,请重新初始化登录',
})
return {
task: nextTask,
session: null,
}
}
}
export async function syncTaskWithSession(task: JsonObject, session: JsonObject) {
const activityInfo = session.activityInfo || null
const patch: JsonObject = {
login_type: String(session.loginType || task.login_type || ''),
updated_at: nowIso(),
}
if (activityInfo?.nickname) {
patch.nickname = String(activityInfo.nickname)
}
if (activityInfo?.role?.ready) {
patch.role_id = String(activityInfo.role.roleId || '')
patch.role_name = String(activityInfo.role.roleName || '')
patch.area = String(activityInfo.role.area || '')
patch.partition_name = String(activityInfo.role.partition || '')
}
if (task.task_status === 'link_generated') {
patch.task_status = 'claimed'
patch.user_action_status = 'claimed'
patch.claimed_at = task.claimed_at || nowIso()
}
if (session.review?.capturedAt) {
patch.state_json = JSON.stringify({
...parseTaskState(task),
reviewScreenshotReady: true,
reviewCapturedAt: String(session.review.capturedAt || ''),
reviewRoleId: String(session.review.roleId || ''),
reviewRoleName: String(session.review.roleName || ''),
})
}
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
patch.screenshot_path = task.screenshot_path || ''
}
return updateTask(task.id, patch)
}
export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) {
const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || ''))
const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status
const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status
const patch = {
task_status: nextTaskStatus,
user_action_status: nextUserActionStatus,
browser_session_id: '',
login_type: '',
nickname: '',
role_id: '',
role_name: '',
area: '',
partition_name: '',
artifacts_json: '{}',
state_json: '{}',
role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at,
last_error: String(lastError || ''),
updated_at: nowIso(),
}
return updateTask(task.id, patch)
}
@@ -1,31 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js'
test('extractAgisoTradePayload preserves large integer order ids from raw json', () => {
const payload = extractAgisoTradePayload({
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
})
assert.equal(payload.biz_order_id, '4502259793206016214')
assert.equal(typeof payload.biz_order_id, 'string')
})
test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => {
const payload = extractAgisoTradePayload({
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
})
assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214')
})
test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => {
assert.equal(
resolveAgisoTradePlatformOrderId({
tid: '4502251008019011224',
orders: [{ oid: '4502251008019011888' }],
}),
'4502251008019011224',
)
})
@@ -1,73 +0,0 @@
import { parseJsonObject } from '../../utils/json.js'
type JsonObject = Record<string, unknown>
export function extractAgisoTradePayload(body: unknown): JsonObject {
const normalizedBody = normalizeRecord(body)
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
if (rawJson) {
return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true }))
}
return normalizedBody
}
export function resolveAgisoTradePlatformOrderId(payload: unknown): string {
const normalizedPayload = normalizeRecord(payload)
const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {}
return pickFirstNonEmpty([
normalizedPayload.biz_order_id,
normalizedPayload.Tid,
normalizedPayload.tid,
normalizedPayload.Oid,
normalizedPayload.oid,
normalizedPayload.order_id,
normalizedPayload.orderId,
firstItem.Oid,
firstItem.oid,
])
}
export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] {
const normalizedPayload = normalizeRecord(payload)
const candidates = [
normalizedPayload.items,
normalizedPayload.Items,
normalizedPayload.orders,
normalizedPayload.Orders,
normalizedPayload.order_list,
normalizedPayload.OrderList,
]
for (const current of candidates) {
if (Array.isArray(current) && current.length > 0) {
return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
}
}
if (Object.keys(normalizedPayload).length > 0) {
return [normalizedPayload]
}
return []
}
function normalizeRecord(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return ''
}
@@ -1,52 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
filterLegacyOrderFulfillmentBindings,
isKuaishouCloudFulfillmentBinding,
} from './fulfillment-binding-config-service.js'
test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => {
assert.equal(
isKuaishouCloudFulfillmentBinding({
provider: '91kaquan',
platform: 'kuaishou',
}),
true,
)
assert.equal(
isKuaishouCloudFulfillmentBinding({
provider: 'agiso',
platform: 'xianyu',
}),
false,
)
})
test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => {
assert.deepEqual(
filterLegacyOrderFulfillmentBindings([
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-a',
},
{
provider: '91kaquan',
platform: 'kuaishou',
shopId: '91kaquan',
skuCode: 'sku-b',
},
]),
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-a',
},
],
)
})
@@ -1,129 +0,0 @@
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js'
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
type JsonObject = Record<string, any>
export function getOrderFulfillmentBindingsFilePath() {
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
}
export function getOrderFulfillmentBindingConfigs() {
return loadOrderFulfillmentBindingConfigsFromFile()
}
export function getLegacyOrderFulfillmentBindingConfigs() {
return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs())
}
export function saveOrderFulfillmentBindingConfigs(rawValue) {
return writeJsonFile(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
rawValue,
normalizeOrderFulfillmentBindingConfigs,
)
}
function loadOrderFulfillmentBindingConfigsFromFile() {
return readJsonFile(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
[],
normalizeOrderFulfillmentBindingConfigs,
)
}
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
if (!Array.isArray(rawValue)) {
return []
}
return rawValue
.map((item) => normalizeOrderFulfillmentBinding(item))
.filter(Boolean)
}
export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) {
return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item))
}
export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) {
return String(binding?.provider || '').trim() === '91kaquan'
&& String(binding?.platform || '').trim() === 'kuaishou'
}
function normalizeOrderFulfillmentBinding(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
const platform = String(rawValue.platform || '').trim()
const shopId = String(rawValue.shopId || '').trim()
const shopName = String(rawValue.shopName || '').trim()
const skuCode = String(rawValue.skuCode || '').trim()
const skuName = String(rawValue.skuName || '').trim()
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
const priority = normalizePriority(rawValue.priority)
const enabled = rawValue.enabled !== false
const config = normalizeJsonObject(rawValue.config)
const match = normalizeOrderFulfillmentMatch(rawValue.match)
if (!skuCode || !match) {
return null
}
return {
provider,
platform,
shopId,
shopName,
skuCode,
skuName,
profileKey,
enabled,
priority,
config,
match,
}
}
function normalizeOrderFulfillmentMatch(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
const externalItemId = String(rawValue.externalItemId || '').trim()
const externalSkuName = String(rawValue.externalSkuName || '').trim()
if (!externalSkuCode && !externalItemId && !externalSkuName) {
return null
}
return {
externalSkuCode,
externalItemId,
externalSkuName,
config: normalizeJsonObject(rawValue.config),
}
}
function normalizePriority(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return 100
}
return Math.max(1, Math.round(parsed))
}
function normalizeJsonObject(value) {
return isPlainObject(value) ? value : {}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,387 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import {
buildWebhookEventInput,
executeAgisoTradeWebhookWithDeps,
parseAgisoTradeRequest,
resolveAgisoTradeIgnoreReason,
shouldEnrichAgisoXianyuTrade,
verifyAgisoSignatureWithSecret,
} from './webhook-service.js'
function createParsedTrade(patch = {}) {
return {
provider: 'agiso',
platform: 'xianyu',
platformRaw: 'xianyu',
eventType: 'payment_success',
eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840',
signatureValid: true,
platformOrderId: '4502259793206016214',
shopId: 'shop-9',
shopName: '咸鱼店铺',
shopIdAliases: ['shop-9'],
orderStatus: 'paid',
payStatus: 'paid',
buyerId: 'buyer-1',
buyerName: '测试买家',
receiverContact: '',
totalAmount: 2550,
currency: 'CNY',
paidAt: '2026-04-14T12:30:40.000Z',
rawPayload: {},
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'DNF礼包',
quantity: 1,
spec: { title: 'DNF礼包' },
},
],
...patch,
}
}
test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => {
const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
const timestamp = '1713097840'
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
const sign = appSecret
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
: ''
const parsed = parseAgisoTradeRequest({
query: {
aopic: '1',
timestamp,
sign,
},
body: {
json: rawJson,
fromPlatform: 'xianyu',
},
})
assert.equal(parsed.provider, 'agiso')
assert.equal(parsed.platform, 'xianyu')
assert.equal(parsed.eventType, 'payment_success')
assert.equal(parsed.signatureValid, true)
assert.equal(parsed.platformOrderId, '4502259793206016214')
assert.equal(parsed.shopId, 'shop-9')
assert.equal(parsed.shopName, '咸鱼店铺')
assert.equal(parsed.payStatus, 'paid')
assert.equal(parsed.orderStatus, 'paid')
assert.equal(parsed.totalAmount, 2550)
assert.equal(parsed.items.length, 1)
assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a')
assert.equal(parsed.items[0]?.quantity, 2)
})
test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => {
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'xianyu',
totalAmount: 0,
buyerName: '',
shopName: '',
items: [],
}), true)
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'xianyu',
totalAmount: 2550,
buyerName: '测试买家',
shopName: '咸鱼店铺',
items: [
{
skuCode: 'dnf-cdk-a',
spec: {
title: 'DNF礼包',
},
},
],
}), false)
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'taobao',
totalAmount: 0,
items: [],
}), false)
})
test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => {
const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
const timestamp = '1776096226'
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
const sign = appSecret
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
: ''
const parsed = parseAgisoTradeRequest({
query: {
aopic: '16',
timestamp,
sign,
},
body: {
json: rawJson,
fromPlatform: 'AldsIdle',
},
})
assert.equal(parsed.platform, 'xianyu')
assert.equal(parsed.eventType, 'buyer_confirm_goods')
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
})
test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => {
const parsed = parseAgisoTradeRequest({
query: {
aopic: '1',
timestamp: '1776096226',
sign: '',
},
body: {
json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}',
fromPlatform: 'xianyu',
},
})
assert.equal(parsed.eventType, 'buyer_confirm_goods')
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
})
test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => {
const appSecret = 'unit-test-secret'
const rawJson = '{"biz_order_id":"4502259793206016214"}'
const timestamp = '1713097840'
const documentedSign = crypto
.createHash('md5')
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
.digest('hex')
const legacySign = crypto
.createHash('md5')
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
.digest('hex')
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }),
true,
)
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }),
true,
)
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }),
false,
)
})
test('buildWebhookEventInput preserves raw request payload for later replay', () => {
const input = buildWebhookEventInput(
{
headers: { 'x-request-id': 'req-1' },
query: { timestamp: '1713097840', sign: 'bad-sign' },
body: { json: '{"biz_order_id":"4502259793206016214"}' },
},
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-9',
shopName: '咸鱼店铺',
eventType: 'payment_success',
eventKey: 'agiso:xianyu:4502259793206016214:payment_success',
signatureValid: false,
},
)
assert.equal(input.provider, 'agiso')
assert.equal(input.platform, 'xianyu')
assert.equal(input.eventType, 'payment_success')
assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success')
assert.equal(input.signatureValid, false)
assert.equal(input.processed, false)
assert.equal(input.processError, '')
assert.equal(input.relatedOrderId, null)
assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' })
assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' })
assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' })
assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/)
})
test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => {
const updates = []
await assert.rejects(
() => executeAgisoTradeWebhookWithDeps(
createParsedTrade({ signatureValid: false }),
701,
{ requestId: 'req-invalid-signature' },
{
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
hasConfiguredOrderItems: async () => {
throw new Error('should not check configured items when signature is invalid')
},
},
),
/验签失败/,
)
assert.deepEqual(updates, [
{
webhookEventId: 701,
patch: {
processed: false,
process_error: '验签失败',
related_order_id: null,
},
},
])
})
test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => {
const updates = []
let upsertCalled = false
const result = await executeAgisoTradeWebhookWithDeps(
createParsedTrade(),
702,
{ requestId: 'req-unconfigured' },
{
hasConfiguredOrderItems: async (input) => {
assert.equal(input.provider, 'agiso')
assert.equal(input.platform, 'xianyu')
assert.equal(input.shopId, 'shop-9')
assert.equal(input.items.length, 1)
return false
},
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
upsertOrderFromWebhook: async () => {
upsertCalled = true
return {}
},
},
)
assert.equal(result.accepted, true)
assert.equal(result.ignored, true)
assert.equal(result.ignoreReason, 'unconfigured_product')
assert.equal(result.orderId, null)
assert.equal(upsertCalled, false)
assert.deepEqual(updates, [
{
webhookEventId: 702,
patch: {
processed: true,
process_error: 'ignored_unconfigured_product',
related_order_id: null,
},
},
])
})
test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => {
const updates = []
const enrichCalls = []
const upsertCalls = []
const parsed = createParsedTrade({
totalAmount: 0,
buyerName: '',
shopName: '',
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'dnf-cdk-a',
quantity: 1,
spec: {},
},
],
})
const result = await executeAgisoTradeWebhookWithDeps(
parsed,
703,
{ requestId: 'req-success' },
{
hasConfiguredOrderItems: async () => true,
enrichAgisoXianyuTradeOrder: async (input, options) => {
enrichCalls.push({ input, options })
return {
parsed: {
...input,
totalAmount: 1990,
buyerName: '补查买家',
shopName: '补查店铺',
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'DNF礼包',
quantity: 1,
spec: { title: 'DNF礼包' },
},
],
},
enriched: true,
reason: '',
}
},
upsertOrderFromWebhook: async (input) => {
upsertCalls.push(input)
return {
ignored: false,
ignoreReason: '',
order: { id: 801 },
tasks: [
{ id: 901, task_no: 'DT901', task_status: 'paid' },
],
messageDeliveries: [{ id: 1001 }],
}
},
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
},
)
assert.equal(enrichCalls.length, 1)
assert.equal(enrichCalls[0].options.requestId, 'req-success')
assert.equal(upsertCalls.length, 1)
assert.equal(upsertCalls[0].totalAmount, 1990)
assert.equal(upsertCalls[0].buyerName, '补查买家')
assert.equal(result.accepted, true)
assert.equal(result.ignored, false)
assert.equal(result.enriched, true)
assert.equal(result.orderId, 801)
assert.equal(result.totalAmountFen, 1990)
assert.equal(result.taskCount, 1)
assert.deepEqual(result.tasks, [
{
taskId: 901,
taskNo: 'DT901',
status: 'paid',
},
])
assert.deepEqual(updates, [
{
webhookEventId: 703,
patch: {
processed: true,
process_error: '',
related_order_id: 801,
},
},
])
})
File diff suppressed because it is too large Load Diff
@@ -1,9 +0,0 @@
## Agiso Platform Services
Keep Agiso-wide shared helpers in this directory.
Per-platform integrations live in subdirectories:
- `xianyu/`
- `pdd/`
- `taobao/`
@@ -1,3 +0,0 @@
## PDD
Put all Agiso PDD-specific service integrations here.
@@ -1,181 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate']
const AGISO_SHOP_CONFIG_KEYS = [
'shopName',
'accessToken',
'messageTemplate',
'autoDeliveryMessageTemplate',
'appSecret',
'apiVersion',
'sendMessageEndpoint',
'tradeDetailEndpoint',
'tradeDetailApiVersion',
'tradeDetailTimeoutMs',
]
type JsonObject = Record<string, unknown>
export type AgisoMessagingDefaults = {
messageTemplate?: string
autoDeliveryMessageTemplate?: string
}
export type AgisoShopConfig = {
enabled?: boolean
shopName?: string
accessToken?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
appSecret?: string
apiVersion?: string
sendMessageEndpoint?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string
}
export type AgisoMessagingConfigDocument = {
defaults: AgisoMessagingDefaults
shops: Record<string, AgisoShopConfig>
}
export function getAgisoShopsFilePath(): string {
return AGISO_SHOPS_FILE_PATH
}
export function getAgisoShopConfigMap(): Record<string, AgisoShopConfig> {
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
const fileConfig = loadAgisoMessagingConfigDocumentFromFile()
return {
...envConfig,
...fileConfig.shops,
}
}
export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return getAgisoShopConfigMap()[normalizedShopId] || null
}
export function getAgisoMessagingDefaults(): AgisoMessagingDefaults {
return loadAgisoMessagingConfigDocumentFromFile().defaults
}
export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument {
const normalized = normalizeAgisoMessagingConfigDocument(rawValue)
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument {
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
return {
defaults: {},
shops: {},
}
}
try {
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeAgisoMessagingConfigDocument(parsed)
} catch {
return {
defaults: {},
shops: {},
}
}
}
function normalizeAgisoShopConfigMap(rawValue: unknown): Record<string, AgisoShopConfig> {
const output: Record<string, AgisoShopConfig> = {}
const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : []
for (const [shopId, config] of entries) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next: AgisoShopConfig = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of AGISO_SHOP_CONFIG_KEYS) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults {
const output: AgisoMessagingDefaults = {}
if (!isPlainObject(rawValue)) {
return output
}
for (const key of AGISO_MESSAGING_DEFAULT_KEYS) {
const value = String(rawValue[key] || '').trim()
if (value) {
output[key] = value
}
}
return output
}
function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument {
const normalizedValue = isPlainObject(rawValue) ? rawValue : {}
const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults')
|| Object.prototype.hasOwnProperty.call(normalizedValue, 'shops')
return {
defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}),
shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue),
}
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,3 +0,0 @@
## Taobao
Put all Agiso Taobao-specific service integrations here.
@@ -1,3 +0,0 @@
## Xianyu
Put all Agiso Xianyu-specific service integrations here.
@@ -1,264 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
hasAgisoAutoDeliverySucceeded,
ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps,
isOrderReadyForAgisoAutoDelivery,
isAgisoAutoDeliverySuccess,
resolveAgisoAutoDeliveryEndpoint,
resolveAgisoAutoDeliveryErrorMessage,
} from './auto-delivery-service.js'
const agisoOrder = {
id: 101,
provider: 'agiso',
platform: 'xianyu',
shop_id: 'shop-auto-delivery-test',
shop_name: '自动发货测试店',
platform_order_id: 'P-AUTO-10001',
}
function createDeliveredTask(patch = {}) {
return {
id: 201,
order_id: agisoOrder.id,
task_status: 'completed',
delivery_status: 'delivered',
context_json: '{}',
...patch,
}
}
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
assert.equal(isOrderReadyForAgisoAutoDelivery([]), false)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'delivered' },
]), true)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'processing' },
]), false)
})
test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: {} },
{ context_json: { agisoAutoDelivery: { status: 'success' } } },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
]), false)
})
test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) },
]), false)
})
test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => {
assert.equal(isAgisoAutoDeliverySuccess(200, {}), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true)
assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false)
})
test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => {
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{ Error_Msg: '库存不足' },
'raw body',
400,
),
'库存不足',
)
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{},
'fallback raw body',
400,
),
'fallback raw body',
)
})
test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => {
assert.equal(
resolveAgisoAutoDeliveryEndpoint(''),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
assert.equal(
resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => {
const calls = {
update: 0,
fetch: 0,
}
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task: createDeliveredTask(),
}, {
listTasksByOrderId: async () => [
createDeliveredTask(),
createDeliveredTask({ id: 202, delivery_status: 'processing' }),
],
updateTask: async () => {
calls.update += 1
return null
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'waiting_other_tasks')
assert.equal(calls.update, 0)
assert.equal(calls.fetch, 0)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {}
try {
const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) })
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: {
...agisoOrder,
shop_id: 'missing-auto-delivery-config-shop',
},
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 1 }
},
fetch: async () => {
throw new Error('fetch should not be called without config')
},
nowIso: () => '2026-05-21T12:00:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'missing_config')
assert.equal(updates.length, 1)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.existing, true)
assert.equal(context.agisoAutoDelivery.status, 'skipped')
assert.equal(context.agisoAutoDelivery.reason, 'missing_config')
assert.deepEqual({
hasEndpoint: context.agisoAutoDelivery.hasEndpoint,
hasAccessToken: context.agisoAutoDelivery.hasAccessToken,
hasAppSecret: context.agisoAutoDelivery.hasAppSecret,
}, {
hasEndpoint: true,
hasAccessToken: false,
hasAppSecret: false,
})
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
const messages = []
runtimeConfig.platforms.agiso.appSecret = 'runtime-secret'
runtimeConfig.platforms.agiso.messaging.shops = {
[agisoOrder.shop_id]: {
accessToken: 'access-token',
appSecret: 'shop-secret',
apiVersion: '1',
},
}
try {
const task = createDeliveredTask()
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
fetch: async () => ({
status: 200,
text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }),
}),
confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({
shipped: false,
orderStatus: 2,
shipTime: 0,
reason: `not shipped: ${input.requestId}`,
}),
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 2 }
},
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => {
messages.push(payload)
return { sent: true }
},
nowIso: () => '2026-05-21T12:01:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, false)
assert.equal(result.reason, 'delivery_not_confirmed')
assert.equal(messages.length, 0)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.agisoAutoDelivery.status, 'failed')
assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed')
assert.equal(context.agisoAutoDelivery.requestId, 'req-1')
assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2)
assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/)
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
@@ -1,640 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../../repositories/task-event-repo.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { queryAgisoXianyuOrderDetail } from './order-detail-service.js'
import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js'
import { parseJsonObject } from '../../../../utils/json.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import type { TaskRow } from '../../../../types/repository-rows.js'
const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
type JsonObject = Record<string, unknown>
type AutoDeliveryOrder = {
id?: number | string
provider?: string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type SupportedAgisoOrder = AutoDeliveryOrder & {
id: number | string
provider: 'agiso'
platform: 'xianyu'
platform_order_id: string
}
type AutoDeliveryTask = Partial<TaskRow> & {
id?: number | string
delivery_status?: string
task_status?: string
context_json?: string | JsonObject | null
[key: string]: unknown
}
type AutoDeliveryTaskWithId = AutoDeliveryTask & {
id: number | string
}
type EnsureAgisoXianyuAutoDeliveryInput = {
order?: AutoDeliveryOrder | null
task?: AutoDeliveryTask | null
trigger?: string
}
type AutoDeliveryStatus = 'success' | 'failed' | 'skipped'
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type AgisoAutoDeliveryConfirmInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type AgisoAutoDeliveryConfirmResult = {
shipped: boolean
orderStatus: number
shipTime: number
reason: string
}
type AutoDeliveryMessageResult = {
sent: boolean
skipped?: boolean
reason?: string
[key: string]: unknown
}
type EnsureAgisoXianyuAutoDeliveryDeps = {
listTasksByOrderId?: (orderId: number | string) => Promise<AutoDeliveryTask[]>
fetch?: FetchLike
confirmAgisoXianyuAutoDeliveryShipped?: (
input: AgisoAutoDeliveryConfirmInput,
) => Promise<AgisoAutoDeliveryConfirmResult>
updateTask?: (
taskId: number | string,
patch: { context_json: string; updated_at: string },
) => Promise<AutoDeliveryTask | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
nowIso?: () => string
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: (
input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null },
) => Promise<AutoDeliveryMessageResult>
}
type PersistAgisoAutoDeliveryResultInput = {
status: AutoDeliveryStatus
trigger?: string
reason?: string
order?: AutoDeliveryOrder | null
responseStatus?: number
response?: unknown
errorMessage?: string
detail?: JsonObject
}
type AgisoAutoDeliveryConfig = {
enabled: boolean
endpoint: string
apiVersion: string
appSecret: string
accessToken: string
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}) {
return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger })
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId
const sendRequest = deps.fetch || (fetch as FetchLike)
const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped
if (!isAgisoXianyuOrder(order) || !task?.id) {
return { sent: false, skipped: true, reason: 'not_supported', task }
}
const currentTask = task as AutoDeliveryTaskWithId
if (String(currentTask.delivery_status || '').trim() !== 'delivered') {
return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask }
}
const orderTasks = await listOrderTasks(order.id)
if (hasAgisoAutoDeliverySucceeded(orderTasks)) {
return { sent: false, skipped: true, reason: 'already_sent', task: currentTask }
}
if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) {
logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', {
trigger,
orderId: order.id,
taskId: currentTask.id,
platformOrderId: order.platform_order_id,
taskStatuses: orderTasks.map((current) => ({
taskId: current.id,
taskStatus: current.task_status,
deliveryStatus: current.delivery_status,
})),
})
return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask }
}
const config = resolveAgisoXianyuAutoDeliveryConfig(order)
if (!config.enabled) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'auto_delivery_disabled',
order,
}, deps)
}
if (!config.endpoint || !config.accessToken || !config.appSecret) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'missing_config',
order,
detail: {
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
},
}, deps)
}
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
const requestBody = buildRequestBody({
platformOrderId: String(order.platform_order_id || '').trim(),
appSecret: config.appSecret,
})
logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
endpoint: config.endpoint,
})
try {
const response = await sendRequest(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = parseJsonObject(rawText) as JsonObject
const success = isAgisoAutoDeliverySuccess(response.status, parsed)
if (success) {
const requestId = String(parsed?.RequestId || '').trim()
// 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。
const confirmResult = await confirmShipped({
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
requestId,
})
if (!confirmResult.shipped) {
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
confirmReason: confirmResult.reason,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'delivery_not_confirmed',
order,
responseStatus: response.status,
response: parsed,
errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
}
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
})
const result = await persistAgisoAutoDeliveryResult(currentTask, {
status: 'success',
trigger,
reason: '',
order,
responseStatus: response.status,
response: parsed,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
// 发货确认成功后,发送自定义消息通知。
await sendAutoDeliveryMessage({ order, task: currentTask }, deps)
return result
}
const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status)
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
errorMessage,
response: parsed,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_failed',
order,
responseStatus: response.status,
response: parsed,
errorMessage,
}, deps)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败')
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
errorMessage,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_error',
order,
responseStatus: 0,
response: {},
errorMessage,
}, deps)
}
}
export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean {
return (Array.isArray(tasks) ? tasks : []).some((task) => {
const context = parseTaskContext(task)
const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {}
return String(autoDelivery.status || '').trim() === 'success'
})
}
export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean {
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
return normalizedTasks.length > 0
&& normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered')
}
function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig {
const baseConfig = runtimeConfig.platforms.agiso.autoDelivery
const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {}
return {
enabled: normalizeBooleanLike(baseConfig.enabled, true),
endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint),
apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1',
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
accessToken: String(shopConfig.accessToken || '').trim(),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: String(apiVersion || '1').trim() || '1',
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp: String(Math.floor(Date.now() / 1000)),
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = String(appSecret || '').trim()
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += String(appSecret || '').trim()
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
export function resolveAgisoAutoDeliveryErrorMessage(
payload: unknown,
rawText: string,
statusCode: number,
): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}`
}
export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string {
return AGISO_DUMMY_SEND_ENDPOINT
}
async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, {
status,
trigger,
reason,
order,
responseStatus = 0,
response = {},
errorMessage = '',
detail = {},
}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const getNowIso = deps.nowIso || nowIso
const patchTask = deps.updateTask || updateTask
const insertTaskEvent = deps.createTaskEvent || createTaskEvent
const now = getNowIso()
const currentContext = parseTaskContext(task)
const nextContext = {
...currentContext,
agisoAutoDelivery: {
status,
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
response: isPlainObject(response) ? response : {},
updatedAt: now,
...detail,
},
}
const updatedTask = await patchTask(task.id, {
context_json: JSON.stringify(nextContext),
updated_at: now,
})
if (status === 'success' || status === 'failed' || status === 'skipped') {
await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, {
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
...detail,
}, now)
}
return {
sent: status === 'success',
skipped: status === 'skipped',
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
response,
task: updatedTask || task,
}
}
function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject {
const value = task?.context_json
if (!value) {
return {}
}
if (isPlainObject(value)) {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
}
function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder {
return String(order?.provider || '').trim() === 'agiso'
&& String(order?.platform || '').trim() === 'xianyu'
&& Boolean(String(order?.platform_order_id || '').trim())
&& Number(order?.id || 0) > 0
}
function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'undefined' || value === null || value === '') {
return fallbackValue
}
const normalized = String(value || '').trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return fallbackValue
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。
* 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过
*/
async function confirmAgisoXianyuAutoDeliveryShipped({
shopId = '',
platformOrderId = '',
requestId = '',
}: AgisoAutoDeliveryConfirmInput = {}): Promise<AgisoAutoDeliveryConfirmResult> {
try {
const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId })
if (detailResult.success && detailResult.shipped) {
return {
shipped: true,
orderStatus: detailResult.orderStatus,
shipTime: detailResult.shipTime,
reason: '',
}
}
return {
shipped: false,
orderStatus: detailResult.orderStatus || 0,
shipTime: detailResult.shipTime || 0,
reason: detailResult.success ? 'order_not_shipped' : detailResult.reason,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', {
requestId,
shopId,
platformOrderId,
errorMessage: message,
}, { level: 'warn' })
return {
shipped: false,
orderStatus: 0,
shipTime: 0,
reason: 'confirm_query_failed',
}
}
}
/**
* 自动发货成功后发送消息通知。
*/
async function sendAutoDeliveryMessage(
{ order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {},
deps: EnsureAgisoXianyuAutoDeliveryDeps = {},
): Promise<AutoDeliveryMessageResult> {
try {
const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
|| ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
const result = await deliverMessage({ order, task })
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
sent: result.sent,
skipped: result.skipped,
reason: result.reason,
})
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
errorMessage: message,
}, { level: 'warn' })
return { sent: false, skipped: true, reason: 'message_send_error' }
}
}
@@ -1,103 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
deliverAgisoXianyuMessageForTaskWithDeps,
normalizeAgisoMessageTemplate,
renderAgisoAutoDeliveryMessage,
} from './message-service.js'
test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => {
const originalMessaging = runtimeConfig.platforms.agiso.messaging
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
runtimeConfig.platforms.agiso.messaging = {
...originalMessaging,
enabled: true,
sendMessageEndpoint: 'https://example.com/send',
apiVersion: '1',
accessToken: 'access-token',
}
runtimeConfig.platforms.agiso.appSecret = 'app-secret'
const calls = {
find: [],
create: 0,
fetch: 0,
}
try {
const result = await deliverAgisoXianyuMessageForTaskWithDeps({
order: {
id: 15,
platform: 'xianyu',
shop_id: '2209880145223',
platform_order_id: '4502280133178028841',
},
task: {
id: 36,
task_no: 'DT7af76e9b2438',
},
channel: 'agiso_im',
messageContent: '测试消息',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
}, {
findLatestSuccessfulMessageDelivery: async (input) => {
calls.find.push(input)
return { id: 16, task_id: null }
},
createMessageDelivery: async () => {
calls.create += 1
return { id: 999 }
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.deepEqual(calls.find, [
{
provider: 'agiso',
platform: 'xianyu',
shopId: '2209880145223',
platformOrderId: '4502280133178028841',
channel: 'agiso_im',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
},
])
assert.equal(calls.create, 0)
assert.equal(calls.fetch, 0)
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'already_sent')
assert.equal(result.deliveryId, 16)
} finally {
runtimeConfig.platforms.agiso.messaging = originalMessaging
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => {
assert.equal(
normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'),
'第一行\n第二行\n第三行',
)
})
test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => {
const message = renderAgisoAutoDeliveryMessage({
order: {
platform_order_id: '4502280133178028841',
shop_id: '2209880145223',
shop_name: '大锤号商',
},
task: {
task_no: 'DT7af76e9b2438',
},
template: '订单 {platformOrderId}\\n结果:{resultMessage}',
})
assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功')
})
@@ -1,539 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js'
import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js'
import {
createMessageDelivery,
findLatestSuccessfulMessageDelivery,
updateMessageDelivery,
} from '../../../../repositories/message-delivery-repo.js'
import { nowIso } from '../../../../utils/time.js'
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery'
type JsonObject = Record<string, unknown>
type MessageOrder = {
id?: number | string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type MessageTask = {
id?: number | string
task_no?: string
[key: string]: unknown
}
type AgisoMessagingConfig = {
enabled?: boolean
sendMessageEndpoint?: string
accessToken?: string
appSecret?: string
apiVersion?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
shopName?: string
[key: string]: unknown
}
type EnsureClaimMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
claimUrl?: string
expiredAt?: string
}
type EnsureAutoDeliveryMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
}
type DeliverMessageInput = EnsureAutoDeliveryMessageInput & {
channel?: string
messageContent?: string
claimUrl?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type MessageDeliveryLike = {
id: number | string
[key: string]: unknown
}
type MessageDeliveryCreateInput = {
provider: string
platform: string
shopId: string
shopName: string
channel: string
orderId?: number | string | null
taskId?: number | string | null
platformOrderId?: string
recipientKey?: string
messageContent: string
claimUrl: string
status: string
requestUrl: string
requestHeadersJson: string
requestBodyJson: string
responseStatus: number
responseJson: string
errorMessage: string
sentAt: string | null
createdAt: string
updatedAt: string
}
type MessageDeliveryPatch = {
status?: string
response_status?: number
response_json?: string
error_message?: string
sent_at?: string | null
updated_at?: string
}
type FindSuccessfulDeliveryInput = {
provider: string
platform: string
shopId: string
platformOrderId: string
channel: string
claimUrl: string
}
type DeliverMessageDeps = {
nowIso?: () => string
findLatestSuccessfulMessageDelivery?: (
input: FindSuccessfulDeliveryInput,
) => Promise<MessageDeliveryLike | null>
createMessageDelivery?: (
input: MessageDeliveryCreateInput,
) => Promise<MessageDeliveryLike | null>
updateMessageDelivery?: (
deliveryId: number | string,
patch: MessageDeliveryPatch,
) => Promise<MessageDeliveryLike | null>
fetch?: FetchLike
}
type RenderClaimMessageInput = EnsureClaimMessageInput & {
template?: string
shopName?: string
}
type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & {
template?: string
shopName?: string
}
type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & {
template?: string
claimUrl?: string
expiredAt?: string
resultMessage?: string
}
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({
order,
task,
claimUrl,
expiredAt,
}: EnsureClaimMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template: config.messageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
messageContent,
claimUrl,
})
}
export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({
order,
task,
}: EnsureAutoDeliveryMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoAutoDeliveryMessage({
order,
task,
template: config.autoDeliveryMessageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL,
messageContent,
})
}
async function deliverAgisoXianyuMessageForTask({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}) {
return deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl,
})
}
export async function deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) {
const now = deps.nowIso || nowIso
const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery
const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery
const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery
const sendRequest = deps.fetch || (fetch as FetchLike)
if (!order || !task || !messageContent) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = resolveAgisoXianyuMessagingConfig(order)
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
const endpoint = String(config.sendMessageEndpoint || '').trim()
const accessToken = String(config.accessToken || '').trim()
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
if (!endpoint || !accessToken || !appSecret) {
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = await findSuccessfulDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
platformOrderId: String(order.platform_order_id || '').trim(),
channel,
claimUrl: String(claimUrl || ''),
})
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
msg: messageContent,
appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken,
apiVersion: String(config.apiVersion || '1').trim() || '1',
})
const createdAt = now()
const delivery = await insertMessageDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
recipientKey: order.platform_order_id,
messageContent,
claimUrl,
status: 'pending',
requestUrl: url,
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
responseStatus: 0,
responseJson: '{}',
errorMessage: '',
sentAt: null,
createdAt,
updatedAt: createdAt,
})
if (!delivery) {
return { sent: false, skipped: false, reason: 'delivery_create_failed' }
}
try {
const response = await sendRequest(url, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = safeParseJson(rawText)
const success = isAgisoSendSuccess(response.status, parsed)
const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status)
const updated = await patchMessageDelivery(delivery.id, {
status: success ? 'success' : 'failed',
response_status: response.status,
response_json: JSON.stringify(parsed ?? { rawText }),
error_message: errorMessage,
sent_at: success ? now() : null,
updated_at: now(),
})
return {
sent: success,
skipped: false,
deliveryId: updated?.id || delivery.id,
responseStatus: response.status,
response: parsed ?? { rawText },
errorMessage,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
await patchMessageDelivery(delivery.id, {
status: 'failed',
response_status: 0,
response_json: '{}',
error_message: message,
sent_at: null,
updated_at: now(),
})
return {
sent: false,
skipped: false,
deliveryId: delivery.id,
errorMessage: message,
}
}
}
function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig {
const baseConfig = runtimeConfig.platforms.agiso.messaging
const fileDefaults = getAgisoMessagingDefaults()
const shopId = String(order?.shop_id || '').trim()
const shopConfigs = getAgisoShopConfigMap()
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
return {
...baseConfig,
...fileDefaults,
...shopConfig,
}
}
function buildRequestUrl(endpoint: string): string {
return new URL(endpoint).toString()
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
tid,
msg,
appSecret,
}: { tid: string; msg: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid,
msg,
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template,
shopName = '',
}: RenderClaimMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
claimUrl,
expiredAt: String(expiredAt || '尽快'),
})
}
export function renderAgisoAutoDeliveryMessage({
order,
task,
template,
shopName = '',
}: RenderAutoDeliveryMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已完成自动发货,请注意查收。'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
resultMessage: '自动发货成功',
})
}
function renderAgisoMessageTemplate({
order,
task,
template,
shopName = '',
claimUrl = '',
expiredAt = '',
resultMessage = '',
}: RenderMessageTemplateInput = {}): string {
const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim()
return normalizeAgisoMessageTemplate(template)
.replaceAll('{platformOrderId}', String(order?.platform_order_id || ''))
.replaceAll('{taskNo}', String(task?.task_no || ''))
.replaceAll('{shopName}', resolvedShopName)
.replaceAll('{shopId}', String(order?.shop_id || ''))
.replaceAll('{claimUrl}', String(claimUrl || ''))
.replaceAll('{expiredAt}', String(expiredAt || ''))
.replaceAll('{resultMessage}', String(resultMessage || ''))
}
function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object') {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText: string): JsonObject | null {
const normalized = String(rawText || '').trim()
if (!normalized) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function maskHeadersForStorage(headers: Record<string, string>): Record<string, string> {
const output = { ...headers }
if (output.Authorization) {
output.Authorization = '[masked]'
}
return output
}
function maskBodyForStorage(body: Record<string, string>): Record<string, string> {
const output = { ...body }
if (output.sign) {
output.sign = '[masked]'
}
return output
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,219 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
enrichAgisoXianyuTradeOrderWithDeps,
queryAgisoXianyuOrderDetailWithDeps,
resolveAgisoXianyuOrderDeliveryState,
} from './order-detail-service.js'
test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => {
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), {
shipped: true,
shipTime: 1713097840,
orderStatus: 0,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), {
shipped: true,
shipTime: 0,
orderStatus: 3,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), {
shipped: false,
shipTime: 0,
orderStatus: 2,
})
})
test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const calls = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
tradeDetailApiVersion: '2',
tradeDetailTimeoutMs: '3000',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10001',
requestId: 'req-detail',
}, {
fetchWithTimeout: async (url, options, timeoutMs) => {
calls.push({ url, options, timeoutMs })
return {
status: 200,
text: async () => JSON.stringify({
IsSuccess: true,
Data: {
total_fee: '25.50',
pay_time: '2026-04-14 20:30:40',
ship_time: 1713097840,
order_status: 3,
item: {
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
},
}),
}
},
})
assert.equal(result.success, true)
assert.equal(result.responseStatus, 200)
assert.equal(result.totalAmountFen, 2550)
assert.equal(result.shipped, true)
assert.equal(result.shipTime, 1713097840)
assert.equal(result.orderStatus, 3)
assert.equal(calls[0]?.url, 'https://example.com/detail')
assert.equal(calls[0]?.timeoutMs, 3000)
assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token')
assert.equal(calls[0]?.options.headers.ApiVersion, '2')
assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/)
assert.match(String(calls[0]?.options.body), /sign=/)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10002',
}, {
fetchWithTimeout: async () => ({
status: 200,
text: async () => JSON.stringify({
IsSuccess: false,
Error_Msg: '订单不存在',
}),
}),
})
assert.equal(result.success, false)
assert.equal(result.reason, 'business_error')
assert.equal(result.errorMessage, '订单不存在')
assert.equal(result.responseStatus, 200)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
let fetchCalled = false
runtimeConfig.platforms.agiso.messaging.shops = {}
runtimeConfig.platforms.agiso.appSecret = ''
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'missing-shop',
platformOrderId: 'P-DETAIL-10003',
}, {
fetchWithTimeout: async () => {
fetchCalled = true
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.success, false)
assert.equal(result.reason, 'missing_config')
assert.equal(fetchCalled, false)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => {
const parsed = {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-detail-test',
shopName: '',
platformOrderId: 'P-DETAIL-10004',
totalAmount: 0,
paidAt: null,
buyerId: '',
buyerName: '',
receiverContact: '',
rawPayload: {},
items: [],
}
const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, {
queryAgisoXianyuOrderDetail: async (input) => {
assert.deepEqual(input, {
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10004',
requestId: 'req-enrich',
})
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: 200,
payload: {},
detailPayload: {
total_fee: '19.90',
pay_time: '2026-04-14 20:30:40',
buyer_id: 'buyer-1',
buyer_name: '测试买家',
receiver_mobile: '13800138000',
shop_name: '详情店铺',
items: [
{
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
],
},
totalAmountFen: 1990,
shipped: false,
shipTime: 0,
orderStatus: 2,
}
},
})
assert.equal(result.enriched, true)
assert.equal(result.totalAmountFen, 1990)
assert.equal(result.parsed.totalAmount, 1990)
assert.equal(result.parsed.buyerId, 'buyer-1')
assert.equal(result.parsed.buyerName, '测试买家')
assert.equal(result.parsed.receiverContact, '13800138000')
assert.equal(result.parsed.shopName, '详情店铺')
assert.equal(result.parsed.items.length, 1)
assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a')
assert.equal(result.parsed.items[0]?.quantity, 2)
assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90')
})
@@ -1,803 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import { parseAmountToFen } from '../../../../utils/money.js'
import { parseJsonObject } from '../../../../utils/json.js'
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
type JsonObject = Record<string, unknown>
type AgisoParsedTrade = {
provider?: string
platform?: string
shopId?: string
shopName?: string
platformOrderId?: string
totalAmount?: number
paidAt?: string | null
buyerId?: string
buyerName?: string
receiverContact?: string
rawPayload?: JsonObject
items?: AgisoOrderItem[]
[key: string]: unknown
}
type AgisoOrderItem = {
skuCode: string
skuName: string
externalItemId: string
externalSkuCode: string
externalSkuName: string
quantity: number
spec: JsonObject
snapshot: {
externalItemId: string
externalSkuCode: string
externalSkuName: string
}
}
type AgisoDetailQueryInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchWithTimeout = (
url: string,
options: RequestInit,
timeoutMs: number,
) => Promise<FetchResponseLike>
type AgisoDetailDeps = {
fetchWithTimeout?: FetchWithTimeout
queryAgisoXianyuOrderDetail?: (
input: Required<AgisoDetailQueryInput>,
) => Promise<AgisoDetailResult>
}
type AgisoTradeDetailConfig = {
endpoint: string
apiVersion: string
accessToken: string
appSecret: string
timeoutMs: number
}
type AgisoShopConfig = {
accessToken?: string
appSecret?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string | number
}
type DeliveryState = {
shipped: boolean
shipTime: number
orderStatus: number
}
type AgisoDetailResult = {
success: boolean
reason: string
errorMessage: string
responseStatus: number
payload: JsonObject
detailPayload: JsonObject
totalAmountFen: number
shipped: boolean
shipTime: number
orderStatus: number
}
type ExternalSkuDescriptor = {
raw: string
externalSkuCode: string
externalSkuName: string
}
export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) {
return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId })
}
export async function enrichAgisoXianyuTradeOrderWithDeps(
parsed: AgisoParsedTrade,
{ requestId = '' } = {},
deps: AgisoDetailDeps = {},
) {
const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
return { parsed, enriched: false, reason: 'not_needed' }
}
const detailResult = await queryOrderDetail({
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
requestId,
})
if (!detailResult.success) {
return {
parsed,
enriched: false,
reason: detailResult.reason,
errorMessage: detailResult.errorMessage,
}
}
const detailPayload = detailResult.detailPayload
if (detailResult.totalAmountFen <= 0) {
return {
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
enriched: false,
reason: 'amount_still_missing',
}
}
const merged = mergeEnrichedTrade(parsed, detailPayload)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
totalAmountFen: merged.totalAmount,
paidAt: merged.paidAt,
})
return {
parsed: merged,
enriched: true,
totalAmountFen: merged.totalAmount,
}
}
export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) {
return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId })
}
export async function queryAgisoXianyuOrderDetailWithDeps(
{ shopId = '', platformOrderId = '', requestId = '' } = {},
deps: AgisoDetailDeps = {},
): Promise<AgisoDetailResult> {
const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout
const normalizedShopId = String(shopId || '').trim()
const normalizedPlatformOrderId = String(platformOrderId || '').trim()
const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId)
if (!config.endpoint || !config.accessToken || !config.appSecret) {
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
}, { level: 'warn' })
return createFailedDetailResult('missing_config')
}
const requestBody = buildRequestBody({
platformOrderId: normalizedPlatformOrderId,
appSecret: config.appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
endpoint: config.endpoint,
})
try {
const response = await requestWithTimeout(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
}, config.timeoutMs)
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject
const detailPayload = extractAgisoDetailPayload(payload)
const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload)
const totalAmountFen = resolveTotalAmountFen(detailPayload)
if (response.status < 200 || response.status >= 300) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('http_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isAgisoDetailSuccess(payload)) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
response: payload,
}, { level: 'warn' })
return createFailedDetailResult('business_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
}, { level: 'warn' })
return createFailedDetailResult('empty_detail_payload', {
responseStatus: response.status,
payload,
})
}
if (totalAmountFen <= 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
detailKeys: Object.keys(detailPayload),
}, { level: 'warn' })
}
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: response.status,
payload: isPlainObject(payload) ? payload : {},
detailPayload,
totalAmountFen,
shipped: delivery.shipped,
shipTime: delivery.shipTime,
orderStatus: delivery.orderStatus,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('request_failed', {
errorMessage: message,
})
}
}
function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean {
return parsed?.provider === 'agiso'
&& parsed?.platform === 'xianyu'
&& String(parsed?.platformOrderId || '').trim()
&& Number(parsed?.totalAmount || 0) <= 0
}
function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig {
const baseConfig = runtimeConfig.platforms.agiso.tradeDetail
const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig
return {
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
accessToken: String(shopConfig.accessToken || '').trim(),
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function extractAgisoDetailPayload(payload) {
if (!isPlainObject(payload)) {
return {}
}
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
if (isPlainObject(payload[key])) {
return payload[key]
}
}
return payload
}
export function resolveAgisoXianyuOrderDeliveryState(payload) {
const shipTime = normalizeTimestampValue(pickFirstNonEmpty([
payload?.ship_time,
payload?.shipTime,
payload?.ShipTime,
payload?.delivery_time,
payload?.deliveryTime,
]))
const orderStatus = normalizeInteger(
pickFirstNonEmpty([
payload?.order_status,
payload?.orderStatus,
payload?.status,
]),
)
return {
shipped: shipTime > 0 || orderStatus >= 3,
shipTime,
orderStatus,
}
}
function resolveTotalAmountFen(payload) {
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
payload.payment,
payload.Payment,
payload.post_fee,
payload.postFee,
payload.item?.price,
]))
if (fenAmount > 0) {
return fenAmount
}
return parseAmountToFen(pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.PayFee,
payload.actual_fee,
payload.actualFee,
payload.ActualFee,
payload.total_amount,
payload.totalAmount,
payload.Amount,
payload.amount,
]))
}
function resolvePaidAt(payload, fallbackValue) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
fallbackValue,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
return nowIso()
}
return null
}
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
const mergedPayload = {
...parsed.rawPayload,
_agisoTradeDetail: detailPayload,
}
const totalAmountFen = resolveTotalAmountFen(detailPayload)
const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items))
return {
...parsed,
buyerId: pickFirstNonEmpty([
parsed.buyerId,
detailPayload.encryption_buyer_id,
detailPayload.buyer_id,
detailPayload.buyerId,
detailPayload.BuyerId,
detailPayload.BuyerOpenUid,
detailPayload.buyer_open_uid,
]),
buyerName: pickFirstNonEmpty([
parsed.buyerName,
detailPayload.buyer_name,
detailPayload.buyerName,
detailPayload.BuyerName,
detailPayload.buyer_nick,
detailPayload.nick,
detailPayload.BuyerNick,
]),
receiverContact: pickFirstNonEmpty([
parsed.receiverContact,
detailPayload.receiver_contact,
detailPayload.receiverContact,
detailPayload.receiver_mobile,
detailPayload.receiverMobile,
detailPayload.mobile,
detailPayload.phone,
]),
shopName: pickFirstNonEmpty([
parsed.shopName,
detailPayload.shop_name,
detailPayload.shopName,
detailPayload.ShopName,
detailPayload.seller_name,
detailPayload.sellerName,
detailPayload.seller_nick,
detailPayload.sellerNick,
detailPayload.SellerNick,
]),
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
rawPayload: mergedPayload,
items: mergedItems,
}
}
function normalizeOrderItems(payload, fallbackItems = []) {
const candidates = [
payload.item ? [payload.item] : null,
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
if (!Array.isArray(items) || items.length === 0) {
return Array.isArray(fallbackItems) ? fallbackItems : []
}
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
]),
)
const skuCode = pickFirstNonEmpty([
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
payload.item_id,
payload.itemId,
])
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
if (Number(payload.success) === 1 || payload.success === true) {
return true
}
return false
}
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
try {
return await fetch(url, {
...options,
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
if (/^\d{10,13}$/.test(normalized)) {
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString()
}
}
const isoLike = normalized.replace(' ', 'T')
const parsed = Date.parse(isoLike)
if (Number.isNaN(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
function normalizeInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.round(parsed) : 0
}
function normalizePositiveInteger(value, fallbackValue) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
}
function normalizeFenInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function normalizeTimestampValue(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function createFailedDetailResult(reason, {
responseStatus = 0,
payload = {},
detailPayload = {},
errorMessage = '',
totalAmountFen = 0,
delivery = { shipped: false, shipTime: 0, orderStatus: 0 },
} = {}) {
return {
success: false,
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
payload: isPlainObject(payload) ? payload : {},
detailPayload: isPlainObject(detailPayload) ? detailPayload : {},
totalAmountFen: Number(totalAmountFen || 0),
shipped: Boolean(delivery?.shipped),
shipTime: Number(delivery?.shipTime || 0),
orderStatus: Number(delivery?.orderStatus || 0),
}
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
-101
View File
@@ -1,101 +0,0 @@
import { runtimeConfig } from '../../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
type OcrAction = 'recognize' | 'batch'
type OcrRequestPayload = Record<string, unknown>
type OcrHttpResult = Record<string, any> & {
code: number
msg?: string
}
export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
return callOcrViaHttp('recognize', payload)
}
export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
return callOcrViaHttp('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupOcrService(): Promise<void> {
await warmupOcrViaHttp()
}
export async function closeOcrService(): Promise<void> {
// HTTP 模式无持久连接,无需关闭。
}
// ── HTTP Call ───────────────────────────────────────────────────────────────
async function callOcrViaHttp(
action: OcrAction,
payload: OcrRequestPayload,
{ timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<OcrHttpResult> {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
if (!baseUrl) {
throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务')
}
const url = `${baseUrl}/ocr/${action}`
let response
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
})
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`)
}
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`)
}
if (!response.ok) {
throw new Error(`OCR 服务返回错误:HTTP ${response.status}`)
}
const result = await response.json()
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new Error('OCR 服务返回了无效响应')
}
const ocrResult = result as OcrHttpResult
if (ocrResult.code !== 0) {
throw new Error(`OCR 服务返回错误:${ocrResult.msg || '未知错误'}`)
}
return ocrResult
}
async function warmupOcrViaHttp(): Promise<void> {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
if (!baseUrl) {
throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查')
}
let response
try {
response = await fetch(`${baseUrl}/health`, {
signal: AbortSignal.timeout(30_000),
})
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`)
}
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`)
}
if (!response.ok) {
throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`)
}
}
@@ -1,308 +0,0 @@
/// <reference lib="dom" />
import { extractHostState, reloadActivityPageForPresentation } from './session-page.js'
import { shouldRefreshLoggedInPresentation } from './session-state.js'
type BrowserPageLike = {
evaluate: <T = any>(callback: any) => Promise<T>
waitForTimeout: (milliseconds: number) => Promise<void>
}
type HostState = {
loginText?: string
unloginVisible?: boolean
loginedVisible?: boolean
}
export type ActivityInfo = {
nickname: string
role: {
ready: boolean
roleName: string
roleId: string
area: string
partition: string
platId: string
md5str: string
checkparam: string
}
form: {
cdkeyInputId: string
cdkeyValue: string
verifyInputId: string
verifyValue: string
verifyImgId: string
submitId: string
}
verify: {
visible: boolean
src: string
naturalWidth: number
naturalHeight: number
}
popup: {
visible: boolean
text: string
detail: string
}
}
type SessionForPresentation = {
page: BrowserPageLike
presentationSyncAttempts: number
}
type EnsureLoggedInPresentationOptions = {
hostState?: HostState | null
credentialReady?: boolean
activityUrl?: string
extractHostState?: (page: BrowserPageLike) => Promise<HostState>
reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise<unknown>
shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean
}
type EnsureActivityInfoReadyOptions = {
triggerRender?: boolean
timeoutMs?: number
extractActivityInfo?: (page: BrowserPageLike) => Promise<ActivityInfo>
triggerActivityRoleRender?: (page: BrowserPageLike) => Promise<unknown>
now?: () => number
}
declare global {
interface Window {
renderCdkey?: () => void
roleData?: Record<string, unknown>
isRole?: unknown
}
}
export function createEmptyActivityInfo(): ActivityInfo {
return {
nickname: '',
role: {
ready: false,
roleName: '',
roleId: '',
area: '',
partition: '',
platId: '',
md5str: '',
checkparam: '',
},
form: {
cdkeyInputId: '',
cdkeyValue: '',
verifyInputId: '',
verifyValue: '',
verifyImgId: '',
submitId: '',
},
verify: {
visible: false,
src: '',
naturalWidth: 0,
naturalHeight: 0,
},
popup: {
visible: false,
text: '',
detail: '',
},
}
}
export async function ensureLoggedInPresentation(
session: SessionForPresentation,
{
hostState = null,
credentialReady = false,
activityUrl = '',
extractHostState: extractHostStateImpl = extractHostState,
reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation,
shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation,
}: EnsureLoggedInPresentationOptions = {},
): Promise<{ reloaded: boolean; hostState: HostState }> {
let nextHostState = hostState || (await extractHostStateImpl(session.page))
if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) {
return {
reloaded: false,
hostState: nextHostState,
}
}
if (session.presentationSyncAttempts >= 2) {
return {
reloaded: false,
hostState: nextHostState,
}
}
session.presentationSyncAttempts += 1
await reloadActivityPageForPresentationImpl(session.page, activityUrl)
nextHostState = await extractHostStateImpl(session.page)
return {
reloaded: true,
hostState: nextHostState,
}
}
export async function ensureActivityInfoReady(
page: BrowserPageLike,
{
triggerRender = false,
timeoutMs = 8_000,
extractActivityInfo: extractActivityInfoImpl = extractActivityInfo,
triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender,
now = () => Date.now(),
}: EnsureActivityInfoReadyOptions = {},
): Promise<ActivityInfo> {
if (triggerRender) {
await triggerActivityRoleRenderImpl(page)
}
const startedAt = now()
let lastInfo = await extractActivityInfoImpl(page)
while (now() - startedAt < timeoutMs) {
if (lastInfo?.role?.ready) {
return lastInfo
}
await page.waitForTimeout(400)
lastInfo = await extractActivityInfoImpl(page)
}
return lastInfo
}
export async function triggerActivityRoleRender(page: BrowserPageLike): Promise<void> {
try {
await page.evaluate(() => {
const maybeRender = window.renderCdkey
if (typeof maybeRender === 'function') {
maybeRender()
}
})
} catch {
// ignore render trigger failures; polling will still inspect the page
}
}
export async function extractActivityInfo(page: BrowserPageLike): Promise<ActivityInfo> {
try {
return await page.evaluate(() => {
const asString = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
const isVisible = (element: Element | null) => {
if (!element) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
}
const pick = (selector: string) => document.querySelector(selector)
const pickBest = (
selector: string,
predicate: ((element: Element) => boolean) | null = null,
): Element | null => {
const matches = [...document.querySelectorAll(selector)]
const preferred = matches.find((element) => {
if (predicate && !predicate(element)) {
return false
}
return isVisible(element)
})
if (preferred) {
return preferred
}
if (predicate) {
return matches.find((element) => predicate(element)) || null
}
return matches[0] || null
}
const textOf = (selector) => asString(pick(selector)?.textContent || '')
const inputOf = (selector) => {
const element = pickBest(
selector,
(node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement,
)
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
return ''
}
return asString(element.value || '')
}
const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {}
const roleSelector = pick('[id^="milo_role_selector_"]')
const roleNameText =
textOf('#role_name') ||
asString(roleSelector instanceof HTMLSelectElement ? roleSelector.selectedOptions?.[0]?.textContent || '' : '')
const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement)
const popup = pick('#pop2')
const cdkeyInput = pickBest(
'[id^="milo_cdkeyInfo_"]',
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden',
)
const verifyInput = pickBest(
'[id^="milo_verifyInput_"]',
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden',
)
const submitButton = pickBest(
'#milo_cdkey_submit, [id^="milo_cdkey_submit"]',
(node) => node instanceof HTMLElement && !node.hasAttribute('disabled'),
)
return {
nickname: textOf('#login_nickname_span'),
role: {
ready: Boolean(window.isRole || roleNameText),
roleName: roleNameText,
roleId: asString(roleData.sRoleId),
area: asString(roleData.sArea || '36'),
partition: asString(roleData.sPartition),
platId: asString(roleData.sPlatId),
md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr),
checkparam: asString(roleData.sCheckparam),
},
form: {
cdkeyInputId: cdkeyInput?.id || '',
cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'),
verifyInputId: verifyInput?.id || '',
verifyValue: inputOf('[id^="milo_verifyInput_"]'),
verifyImgId: verifyImg?.id || '',
submitId: submitButton?.id || '',
},
verify: {
visible: isVisible(verifyImg),
src: asString(verifyImg?.getAttribute('src')),
naturalWidth: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalWidth || 0) : 0,
naturalHeight: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalHeight || 0) : 0,
},
popup: {
visible: isVisible(popup),
text: textOf('#PopText'),
detail: textOf('#PopText2'),
},
}
})
} catch {
return createEmptyActivityInfo()
}
}
@@ -1,135 +0,0 @@
import process from 'node:process'
export type LoginType = 'qq' | 'wx'
export const DEFAULT_LOGIN_TYPE: LoginType = 'qq'
type BrowserConfigInput = {
headless?: boolean
devtools?: boolean
keepAlive?: boolean
prewarm?: boolean
slowMoMs?: number
}
type ResolveBrowserLaunchOptionsInput = {
browserConfig?: BrowserConfigInput
chromePath?: string
nodeEnv?: string
ci?: string
}
export type BrowserLaunchOptions = {
headless: boolean
devtools: boolean
usesBundledChromium: boolean
keepAlive: boolean
prewarm: boolean
slowMoMs: number
}
export type ViewportSize = {
width: number
height: number
}
export function resolveBrowserLaunchOptions(options: ResolveBrowserLaunchOptionsInput = {}): BrowserLaunchOptions {
const browserConfig = options.browserConfig || {}
const chromePath = options.chromePath || ''
const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV
const ci = options.ci ?? process.env.CI
const explicitHeadless = browserConfig.headless
const explicitDevtools = browserConfig.devtools
const explicitKeepAlive = browserConfig.keepAlive
const explicitPrewarm = browserConfig.prewarm
const explicitSlowMo = browserConfig.slowMoMs
const isProductionLike = nodeEnv === 'production' || ci === '1'
const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike
const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless
const slowMoMs = Number.isFinite(explicitSlowMo)
? Math.max(0, Math.min(explicitSlowMo, 2_000))
: headless
? 0
: 150
const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike
const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive
return {
headless,
devtools,
usesBundledChromium: !chromePath,
keepAlive,
prewarm,
slowMoMs,
}
}
export function buildChromiumLaunchOptions(
launchOptions: BrowserLaunchOptions,
{ chromePath = '' }: { chromePath?: string } = {},
) {
const options = {
headless: launchOptions.headless,
devtools: launchOptions.devtools,
slowMo: launchOptions.slowMoMs,
}
if (chromePath) {
return {
...options,
executablePath: chromePath,
}
}
return options
}
export function resolveDefaultViewport(rawValue?: unknown): ViewportSize {
const fromVncResolution = parseViewportSpec(rawValue)
if (fromVncResolution) {
return fromVncResolution
}
return { width: 1600, height: 900 }
}
export function parseViewportSpec(rawValue: unknown): ViewportSize | null {
const text = String(rawValue || '').trim()
if (!text) {
return null
}
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
if (!match) {
return null
}
const width = Number(match[1])
const height = Number(match[2])
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
return null
}
return { width, height }
}
export function normalizeLoginType(value: unknown, fallback: LoginType = DEFAULT_LOGIN_TYPE): LoginType {
const normalized = String(value || fallback).trim().toLowerCase()
if (['qq', 'qc'].includes(normalized)) {
return 'qq'
}
if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) {
return 'wx'
}
return fallback
}
export function getLoginTypeLabel(loginType: LoginType | string): string {
return loginType === 'wx' ? '微信' : 'QQ'
}
@@ -1,49 +0,0 @@
const TENCENT_COOKIE_URLS = [
'https://df.qq.com',
'https://ssl.captcha.qq.com',
'https://graph.qq.com',
'https://milo.qq.com',
]
type BrowserCookie = {
name?: string
value?: string
}
type BrowserContextLike = {
cookies: (urls?: string[]) => Promise<BrowserCookie[]>
}
export type TencentCookieMap = Record<string, string>
export function normalizeTencentCookieMap(cookies: BrowserCookie[] = []): TencentCookieMap {
const cookieMap: TencentCookieMap = {}
for (const cookie of cookies) {
if (!cookie?.name) {
continue
}
cookieMap[String(cookie.name)] = String(cookie.value || '')
}
if (!cookieMap.acctype) {
cookieMap.acctype = 'qc'
}
if (!cookieMap.appid) {
cookieMap.appid = '101491592'
}
if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) {
cookieMap.iegams_milo_proxylogin_qc =
`${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}`
}
return cookieMap
}
export async function loadCookieMapFromContext(browserContext: BrowserContextLike): Promise<TencentCookieMap> {
const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS)
return normalizeTencentCookieMap(cookies)
}
@@ -1,113 +0,0 @@
import { runtimeConfig } from '../../config/runtime.js'
import { logDebug } from '../../utils/logger.js'
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
type FrameLike = unknown
type PageWithFrames = {
frames: () => FrameLike[]
waitForTimeout: (milliseconds: number) => Promise<void>
}
type QrDownloadResponse = {
ok: () => boolean
status: () => number
body: () => Promise<Buffer>
}
type PageWithRequestContext = {
context: () => {
request: {
get: (url: string, options?: {
failOnStatusCode?: boolean
headers?: Record<string, string>
}) => Promise<QrDownloadResponse>
}
}
}
export function logBrowserSessionDebug(scope: string, detail?: unknown): void {
if (!SESSION_DEBUG_ENABLED) {
return
}
logDebug('[browser/session]', scope, detail)
}
export async function waitForFrame<TFrame extends FrameLike>(
page: PageWithFrames,
predicate: (frame: TFrame) => boolean,
timeoutMs = 15_000,
): Promise<TFrame> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const matched = page.frames().find((frame): frame is TFrame => {
try {
return predicate(frame as TFrame)
} catch {
return false
}
})
if (matched) {
return matched
}
await page.waitForTimeout(300)
}
throw new Error('未找到登录二维码 frame')
}
export async function downloadRemoteQrImage(
page: PageWithRequestContext,
qrUrl: string,
qrImagePath: string,
referer = '',
loginType = 'unknown',
): Promise<Buffer | false | null> {
try {
const response = await page.context().request.get(qrUrl, {
failOnStatusCode: false,
headers: {
referer,
},
})
if (!response.ok()) {
logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, {
qrUrl,
status: response.status(),
})
return false
}
const body = await response.body()
if (!body?.length) {
return false
}
return body
} catch (error) {
logBrowserSessionDebug(`${loginType}.capture.downloadError`, {
qrUrl,
message: error instanceof Error ? error.message : String(error),
})
return null
}
}
export function isRetryableQrCaptureError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '')
return (
message.includes('Frame was detached') ||
message.includes('Execution context was destroyed') ||
message.includes('Target page, context or browser has been closed') ||
message.includes('waiting for') ||
message.includes('Timeout')
)
}
@@ -1,244 +0,0 @@
/// <reference lib="dom" />
import fs from 'node:fs/promises'
import path from 'node:path'
import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js'
import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js'
import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js'
type LoginType = 'qq' | 'wx' | string
type LocatorLike = {
count: () => Promise<number>
first: () => LocatorLike
click: (options?: { timeout?: number }) => Promise<void>
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
screenshot: (options: { path: string }) => Promise<void>
}
type BrowserPageLike = {
frames: () => any[]
locator: (selector: string) => LocatorLike
waitForTimeout: (milliseconds: number) => Promise<void>
evaluate: <T = any>(callback: any) => Promise<T>
reload: (options?: { waitUntil?: string }) => Promise<unknown>
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
context: () => any
}
type SessionForQrCapture = {
page: BrowserPageLike
loginType: LoginType
sessionDir: string
qrImagePath?: string
qrImageBase64?: string
qrUpdatedAt?: string
updatedAt?: string
}
type CaptureSessionQrOptions = {
ensureLoginReady?: boolean
ensureLoginTab?: typeof ensureLoginTab
captureQrImageWithRetry?: typeof captureQrImageWithRetry
readFile?: (path: string, encoding: BufferEncoding) => Promise<string>
now?: () => string
}
type CaptureQrImageWithRetryOptions = {
captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
ensureLoginTab?: typeof ensureLoginTab
isRetryableQrCaptureError?: (error: unknown) => boolean
logBrowserSessionDebug?: (scope: string, detail?: unknown) => void
}
type EnsureLoginTabOptions = {
ensureQqLoginReady?: (page: BrowserPageLike) => Promise<unknown>
ensureWxLoginReady?: (page: BrowserPageLike) => Promise<unknown>
}
type ExtractQrStateOptions = {
extractQqQrState?: (page: BrowserPageLike) => Promise<unknown>
extractWxQrState?: (page: BrowserPageLike) => Promise<unknown>
}
export async function captureSessionQr(
session: SessionForQrCapture,
{
ensureLoginReady = true,
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry,
readFile = fs.readFile,
now = () => new Date().toISOString(),
}: CaptureSessionQrOptions = {},
): Promise<void> {
if (ensureLoginReady) {
await ensureLoginTabImpl(session.page, session.loginType)
}
const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`)
await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, {
ensureLoginTab: ensureLoginTabImpl,
})
session.qrImagePath = qrImagePath
session.qrImageBase64 = await readFile(qrImagePath, 'base64')
session.qrUpdatedAt = now()
session.updatedAt = session.qrUpdatedAt
}
export async function captureQrImageWithRetry(
page: BrowserPageLike,
loginType: LoginType,
qrImagePath: string,
{
captureQqQrImage: captureQqQrImageImpl = captureQqQrImage,
captureWxQrImage: captureWxQrImageImpl = captureWxQrImage,
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError,
logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug,
}: CaptureQrImageWithRetryOptions = {},
): Promise<void> {
const maxAttempts = loginType === 'qq' ? 4 : 3
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
logBrowserSessionDebugImpl('captureQrImageWithRetry.start', {
loginType,
attempt,
maxAttempts,
qrImagePath,
})
if (loginType === 'qq') {
await captureQqQrImageImpl(page, qrImagePath)
} else {
await captureWxQrImageImpl(page, qrImagePath)
}
logBrowserSessionDebugImpl('captureQrImageWithRetry.success', {
loginType,
attempt,
qrImagePath,
})
return
} catch (error) {
lastError = error
logBrowserSessionDebugImpl('captureQrImageWithRetry.error', {
loginType,
attempt,
message: error instanceof Error ? error.message : String(error),
})
if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) {
throw error
}
await page.waitForTimeout(500)
await ensureLoginTabImpl(page, loginType)
}
}
if (lastError) {
throw lastError
}
throw new Error('二维码截图失败')
}
export async function ensureLoginTab(
page: BrowserPageLike,
loginType: LoginType,
{
ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady,
ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady,
}: EnsureLoginTabOptions = {},
): Promise<void> {
const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab'
const loginButton = page.locator('#unlogin')
let interacted = false
if (await loginButton.count()) {
try {
await loginButton.first().click({ timeout: 2_000 })
interacted = true
} catch {
// ignore when the login layer is already open
}
}
const tab = page.locator(tabSelector).first()
if (await tab.count()) {
try {
await tab.click({ timeout: 2_000 })
interacted = true
} catch {
// ignore tab click failures
}
}
if (interacted) {
await page.waitForTimeout(250)
}
if (loginType === 'qq') {
await ensureQqLoginReadyImpl(page)
return
}
if (loginType === 'wx') {
await ensureWxLoginReadyImpl(page)
}
}
export async function extractHostState(page: BrowserPageLike) {
return page.evaluate(() => {
const isVisible = (element: Element | null) => {
if (!element) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
}
const unlogin = document.querySelector('#unlogin')
const logined = document.querySelector('#logined')
return {
unloginVisible: isVisible(unlogin),
loginedVisible: isVisible(logined),
loginText: String(logined?.textContent || '').trim(),
}
})
}
export async function reloadActivityPageForPresentation(page: BrowserPageLike, activityUrl: string): Promise<void> {
try {
await page.reload({ waitUntil: 'domcontentloaded' })
} catch {
await page.goto(activityUrl, { waitUntil: 'domcontentloaded' })
}
await page.waitForTimeout(2_500)
}
export async function extractQrState(
page: BrowserPageLike,
loginType: LoginType,
{
extractQqQrState: extractQqQrStateImpl = extractQqQrState,
extractWxQrState: extractWxQrStateImpl = extractWxQrState,
}: ExtractQrStateOptions = {},
): Promise<unknown> {
if (loginType === 'qq') {
return extractQqQrStateImpl(page)
}
return extractWxQrStateImpl(page)
}
@@ -1,190 +0,0 @@
export function buildSessionPayload(
session: TencentBrowserSessionPayloadSource,
{
activityUrl = '',
includeQrImage = true,
sessionDebugEnabled = false,
browserDebug = null,
}: BuildSessionPayloadOptions = {},
): any {
const activityInfo = session.lastState?.activityInfo || null
const payload: Record<string, any> = {
sessionId: session.sessionId,
loginType: session.loginType,
activityUrl,
status: session.status,
notice: session.notice,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
expiresAt: new Date(session.expiresAt).toISOString(),
qrUpdatedAt: session.qrUpdatedAt,
credentialReady: Boolean(session.lastState?.credentialReady),
activityInfo,
review: buildReviewPayload(session.lastReview),
redeem: buildRedeemPayload(session.lastRedeem),
artifacts: buildArtifactsPayload(session),
}
if (includeQrImage) {
payload.qrImageBase64 = session.qrImageBase64
}
if (!sessionDebugEnabled) {
return payload
}
return {
...payload,
cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [],
state: session.lastState,
browserDebug,
qrImagePath: session.qrImagePath,
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
}
}
type BuildSessionPayloadOptions = {
activityUrl?: string
includeQrImage?: boolean
sessionDebugEnabled?: boolean
browserDebug?: unknown
}
type InternalPathOptions = {
includeInternalPaths?: boolean
}
type TencentBrowserSessionPayloadSource = {
sessionId: string
loginType?: string
status?: string
notice?: string
createdAt?: string
updatedAt?: string
expiresAt: string | number | Date
qrUpdatedAt?: string
qrImageBase64?: string
qrImagePath?: string
sessionDir?: string
lastState?: {
credentialReady?: boolean
cookieKeys?: string[]
activityInfo?: any
[key: string]: unknown
} | null
lastReview?: ReviewPayloadSource | null
lastRedeem?: RedeemPayloadSource | null
}
type ReviewPayloadSource = {
capturedAt?: string
roleId?: string
roleName?: string
screenshotPath?: string
signature?: string
}
type RedeemAttemptPayloadSource = {
attempt?: number
redeem?: unknown
}
type RedeemPayloadSource = {
code?: string
area?: string
proofMode?: string
attempts?: RedeemAttemptPayloadSource[]
final?: RedeemAttemptPayloadSource | null
finishedAt?: string
screenshotPath?: string
htmlPath?: string
resultPath?: string
}
export function buildReviewPayload(review: ReviewPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) {
if (!review) {
return null
}
const payload = {
capturedAt: review.capturedAt,
roleId: review.roleId || '',
roleName: review.roleName || '',
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
screenshotPath: review.screenshotPath || '',
signature: review.signature || '',
}
}
export function buildRedeemPayload(redeem: RedeemPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) {
if (!redeem) {
return null
}
const payload = {
code: redeem.code,
area: redeem.area,
proofMode: redeem.proofMode || 'full',
attempts: Array.isArray(redeem.attempts)
? redeem.attempts.map((attempt) => ({
attempt: attempt.attempt,
redeem: attempt.redeem || null,
}))
: [],
final: redeem.final
? {
attempt: redeem.final.attempt,
redeem: redeem.final.redeem || null,
}
: null,
finishedAt: redeem.finishedAt,
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
screenshotPath: redeem.screenshotPath || '',
htmlPath: redeem.htmlPath || '',
resultPath: redeem.resultPath || '',
}
}
export function buildArtifactsPayload(
session: Pick<
TencentBrowserSessionPayloadSource,
'sessionDir' | 'qrImagePath' | 'qrImageBase64' | 'lastReview' | 'lastRedeem'
>,
{ includeInternalPaths = false }: InternalPathOptions = {},
) {
const payload = {
hasQrImage: Boolean(session.qrImageBase64),
hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath),
hasScreenshot: Boolean(session.lastRedeem?.screenshotPath),
}
if (!includeInternalPaths) {
return payload
}
return {
...payload,
sessionDir: session.sessionDir,
qrImagePath: session.qrImagePath,
screenshotPath: session.lastRedeem?.screenshotPath || '',
htmlPath: session.lastRedeem?.htmlPath || '',
resultPath: session.lastRedeem?.resultPath || '',
reviewScreenshotPath: session.lastReview?.screenshotPath || '',
}
}
@@ -1,55 +0,0 @@
/// <reference lib="dom" />
import {
BAIDU_BEIJING_TIME_URL,
BEIJING_TIME_PROOF_CLIP,
BEIJING_TIME_PROOF_VIEWPORT,
} from './session-proof-constants.js'
type BrowserPageLike = {
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
waitForTimeout: (milliseconds: number) => Promise<void>
evaluate: (callback: () => unknown) => Promise<unknown>
screenshot: (options: {
path: string
clip: { x: number; y: number; width: number; height: number }
}) => Promise<unknown>
close: () => Promise<void>
}
type BrowserContextLike = {
newPage: () => Promise<BrowserPageLike>
}
export type BeijingTimeProof = {
screenshotPath: string
pageUrl: string
}
export async function captureBeijingTimeProof(
browserContext: BrowserContextLike,
{ screenshotPath }: { screenshotPath: string },
): Promise<BeijingTimeProof> {
const proofPage = await browserContext.newPage()
try {
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
await proofPage.waitForTimeout(3_000)
await proofPage.evaluate(() => {
window.scrollTo(0, 0)
})
await proofPage.screenshot({
path: screenshotPath,
clip: BEIJING_TIME_PROOF_CLIP,
})
return {
screenshotPath,
pageUrl: BAIDU_BEIJING_TIME_URL,
}
} finally {
await proofPage.close().catch(() => null)
}
}
@@ -1,11 +0,0 @@
export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1'
export type RedeemProofMode = 'full' | 'basic' | 'off'
export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
export const BEIJING_TIME_PROOF_CLIP = {
x: 0,
y: 0,
width: BEIJING_TIME_PROOF_VIEWPORT.width,
height: 620,
}
export const DEFAULT_REDEEM_PROOF_MODE: RedeemProofMode = 'full'
@@ -1,180 +0,0 @@
export type ProofHtmlInput = {
redeemImageBase64: string
timeImageBase64: string
redeemMessage?: string
timePageUrl: string
}
export function buildProofHtml({
redeemImageBase64,
timeImageBase64,
redeemMessage,
timePageUrl,
}: ProofHtmlInput): string {
const capturedAt = new Date().toLocaleString('zh-CN', {
hour12: false,
timeZone: 'Asia/Shanghai',
})
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>兑换与北京时间凭证</title>
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
color: #142033;
}
.sheet {
width: 1480px;
margin: 0 auto;
padding: 30px 24px 28px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-end;
padding: 0 6px 18px;
}
.eyebrow {
margin: 0 0 12px;
color: #1c78d0;
font-size: 13px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 44px;
line-height: 1.08;
}
.summary {
margin: 14px 0 0;
font-size: 18px;
line-height: 1.7;
color: #52627a;
}
.meta {
min-width: 320px;
padding: 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
}
.meta strong,
.meta span {
display: block;
}
.meta strong {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #60748d;
}
.meta span {
margin-top: 8px;
font-size: 20px;
color: #142033;
line-height: 1.5;
overflow-wrap: anywhere;
}
.card {
margin-top: 16px;
padding: 20px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
}
.card h2 {
margin: 0;
font-size: 30px;
}
.card p {
margin: 8px 0 0;
color: #5a6b83;
line-height: 1.65;
font-size: 16px;
}
.preview {
margin-top: 14px;
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(20, 32, 51, 0.08);
background: #dfe7f3;
}
.preview img {
display: block;
width: 100%;
height: auto;
}
.preview--time {
max-height: 500px;
}
.preview--time img {
object-fit: cover;
object-position: top center;
}
.url {
margin-top: 10px;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 14px;
color: #58708f;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main class="sheet">
<section class="hero">
<div>
<p class="eyebrow">Tencent Redeem Proof</p>
<h1>兑换截图与北京时间截图</h1>
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
</div>
<div class="meta">
<strong>Captured At</strong>
<span>${escapeHtml(capturedAt)}</span>
</div>
</section>
<section class="card">
<h2>兑换结果截图</h2>
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
<div class="preview">
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
</div>
</section>
<section class="card">
<h2>百度检索“北京时间”截图</h2>
<p>单独标签页打开百度搜索结果并截图。</p>
<div class="url">${escapeHtml(timePageUrl)}</div>
<div class="preview preview--time">
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
</div>
</section>
</main>
</body>
</html>`
}
export function escapeHtml(value: unknown): string {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -1,38 +0,0 @@
import { runtimeConfig } from '../../config/runtime.js'
import { DEFAULT_REDEEM_PROOF_MODE, type RedeemProofMode } from './session-proof-constants.js'
type RedeemResultForProof = {
classification?: {
success?: boolean
}
redeem?: {
iRet?: number | string
ret?: number | string
}
} | null | undefined
export function resolveRedeemProofMode(rawMode: unknown = runtimeConfig.redeem.proofMode): RedeemProofMode {
const normalized = String(rawMode || '')
.trim()
.toLowerCase()
if (normalized === 'basic' || normalized === 'off') {
return normalized
}
return DEFAULT_REDEEM_PROOF_MODE
}
export function shouldCaptureBeijingTimeProof(finalResult: RedeemResultForProof): boolean {
if (finalResult?.classification?.success === true) {
return true
}
if (finalResult?.classification?.success === false) {
return false
}
const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret)
return Number.isFinite(retCode) && retCode === 0
}
@@ -1,19 +0,0 @@
import path from 'node:path'
export type RedeemArtifactPaths = {
redeemPageScreenshotPath: string
beijingTimeScreenshotPath: string
screenshotPath: string
htmlPath: string
resultPath: string
}
export function buildArtifactPaths(outputDir: string): RedeemArtifactPaths {
return {
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
screenshotPath: path.join(outputDir, 'redeem-result.png'),
htmlPath: path.join(outputDir, 'page.html'),
resultPath: path.join(outputDir, 'result.json'),
}
}
@@ -1,131 +0,0 @@
/// <reference lib="dom" />
import fs from 'node:fs/promises'
import { buildProofHtml } from './session-proof-html.js'
type BrowserPageLike = {
evaluate: <T = unknown>(callback: any, arg?: unknown) => Promise<T>
waitForTimeout: (milliseconds: number) => Promise<void>
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
setContent: (html: string, options?: { waitUntil?: string }) => Promise<unknown>
screenshot: (options: { path: string; fullPage?: boolean }) => Promise<unknown>
close: () => Promise<void>
}
type BrowserContextLike = {
newPage: () => Promise<BrowserPageLike>
}
type ComposeProofScreenshotOptions = {
outputPath: string
redeemImagePath: string
timeImagePath: string
redeemMessage?: string
timePageUrl: string
}
export async function showResultDialog(page: BrowserPageLike, message: string): Promise<void> {
await page.evaluate((text) => {
document.querySelectorAll('iframe').forEach((element) => element.remove())
document.querySelectorAll('.pop').forEach((element) => {
if (element instanceof HTMLElement) {
element.style.display = 'none'
}
})
let card = document.getElementById('codex-redeem-result')
if (!card) {
card = document.createElement('div')
card.id = 'codex-redeem-result'
card.innerHTML = `
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
<div class="codex-redeem-result__title">兑换结果</div>
<div class="codex-redeem-result__message"></div>
`
document.body.appendChild(card)
}
const messageNode = card.querySelector('.codex-redeem-result__message')
if (messageNode) {
messageNode.textContent = text
}
Object.assign(card.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '99999',
width: 'min(560px, calc(100vw - 80px))',
padding: '32px 36px',
borderRadius: '24px',
background: 'rgba(10, 18, 28, 0.92)',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(109, 241, 202, 0.25)',
color: '#f4fbff',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
})
const styleId = 'codex-redeem-result-style'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
#codex-redeem-result .codex-redeem-result__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6df1ca;
margin-bottom: 12px;
}
#codex-redeem-result .codex-redeem-result__title {
font-size: 34px;
font-weight: 700;
margin-bottom: 16px;
}
#codex-redeem-result .codex-redeem-result__message {
font-size: 24px;
line-height: 1.5;
color: #ffffff;
}
`
document.head.appendChild(style)
}
window.scrollTo(0, 0)
}, message)
await page.waitForTimeout(600)
}
export async function composeProofScreenshot(
browserContext: BrowserContextLike,
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }: ComposeProofScreenshotOptions,
): Promise<void> {
const [redeemImageBase64, timeImageBase64] = await Promise.all([
fs.readFile(redeemImagePath, 'base64'),
fs.readFile(timeImagePath, 'base64'),
])
const composePage = await browserContext.newPage()
try {
await composePage.setViewportSize({ width: 1520, height: 1900 })
await composePage.setContent(
buildProofHtml({
redeemImageBase64,
timeImageBase64,
redeemMessage,
timePageUrl,
}),
{ waitUntil: 'domcontentloaded' },
)
await composePage.screenshot({ path: outputPath, fullPage: true })
} finally {
await composePage.close().catch(() => null)
}
}
@@ -1,65 +0,0 @@
import fs from 'node:fs/promises'
import { BAIDU_BEIJING_TIME_URL, type RedeemProofMode } from './session-proof-constants.js'
type RedeemResultFilePayload = {
proofMode: RedeemProofMode
sessionId?: string
activityUrl: string
code: string
finalResult?: {
role?: {
area?: string
}
[key: string]: unknown
} | null
attempts?: unknown[]
screenshotPath: string
htmlPath?: string
redeemPageScreenshotPath?: string
beijingTimeScreenshotPath?: string
beijingTimePageUrl?: string
beijingTimeError?: string
}
export async function writeRedeemResultFile(
resultPath: string,
{
proofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath = '',
redeemPageScreenshotPath = '',
beijingTimeScreenshotPath = '',
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
beijingTimeError = '',
}: RedeemResultFilePayload,
): Promise<void> {
await fs.writeFile(
resultPath,
JSON.stringify(
{
proofMode,
sessionId,
activityUrl,
code,
area: finalResult?.role?.area || '',
final: finalResult,
attempts,
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
beijingTimePageUrl,
beijingTimeError,
screenshotPath,
htmlPath,
},
null,
2,
),
'utf8',
)
}
@@ -1,81 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js'
import { buildProofHtml } from './session-proof-html.js'
import { buildArtifactPaths } from './session-proof-paths.js'
test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => {
assert.equal(resolveRedeemProofMode('basic'), 'basic')
assert.equal(resolveRedeemProofMode(' OFF '), 'off')
assert.equal(resolveRedeemProofMode('full'), 'full')
assert.equal(resolveRedeemProofMode('unexpected'), 'full')
assert.equal(resolveRedeemProofMode(''), 'full')
})
test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => {
assert.equal(
shouldCaptureBeijingTimeProof({
classification: {
success: true,
},
}),
true,
)
assert.equal(
shouldCaptureBeijingTimeProof({
classification: {
success: false,
},
}),
false,
)
assert.equal(
shouldCaptureBeijingTimeProof({
redeem: {
iRet: 0,
},
}),
true,
)
assert.equal(
shouldCaptureBeijingTimeProof({
redeem: {
iRet: -183,
},
}),
false,
)
assert.equal(shouldCaptureBeijingTimeProof(null), false)
})
test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => {
const artifactPaths = buildArtifactPaths('/tmp/redeem-proof')
assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png'))
assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png'))
assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png'))
assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html'))
assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json'))
})
test('buildProofHtml escapes injected text while keeping proof sections stable', () => {
const html = buildProofHtml({
redeemImageBase64: 'redeem-base64',
timeImageBase64: 'time-base64',
redeemMessage: '<b>兑换成功 & 已到账</b>',
timePageUrl: 'https://example.com/?q=<北京时间>',
})
assert.match(html, /Tencent Redeem Proof/)
assert.match(html, /兑换截图与北京时间截图/)
assert.match(html, /&lt;b&gt;兑换成功 &amp; 已到账&lt;\/b&gt;/)
assert.match(html, /https:\/\/example\.com\/\?q=&lt;北京时间&gt;/)
assert.match(html, /data:image\/png;base64,redeem-base64/)
assert.match(html, /data:image\/png;base64,time-base64/)
})
@@ -1,205 +0,0 @@
import fs from 'node:fs/promises'
import {
BAIDU_BEIJING_TIME_URL,
DEFAULT_REDEEM_PROOF_MODE,
type RedeemProofMode,
} from './session-proof-constants.js'
import { captureBeijingTimeProof } from './session-proof-beijing-time.js'
import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js'
import { buildArtifactPaths } from './session-proof-paths.js'
import { writeRedeemResultFile } from './session-proof-result-writer.js'
type BrowserPageLike = {
evaluate: <T = unknown>(callback: any, arg?: unknown) => Promise<T>
waitForTimeout: (milliseconds: number) => Promise<void>
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
setContent: (html: string, options?: { waitUntil?: string }) => Promise<unknown>
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
screenshot: (options: {
path: string
fullPage?: boolean
clip?: { x: number; y: number; width: number; height: number }
}) => Promise<unknown>
content: () => Promise<string>
close: () => Promise<void>
}
type BrowserContextLike = {
newPage: () => Promise<BrowserPageLike>
}
type RedeemFinalResult = {
role?: {
area?: string
}
classification?: {
success?: boolean
}
redeem?: {
iRet?: number | string
ret?: number | string
sMsg?: string
}
[key: string]: unknown
}
type RedeemArtifactsInput = {
browserContext: BrowserContextLike
page: BrowserPageLike
sessionDir: string
sessionId?: string
activityUrl: string
code: string
finalResult: RedeemFinalResult
attempts?: unknown[]
}
type WriteRedeemArtifactFilesInput = Omit<RedeemArtifactsInput, 'sessionDir'> & {
outputDir: string
proofMode?: RedeemProofMode | string
}
type RedeemArtifactsResult = {
proofMode: RedeemProofMode
screenshotPath: string
htmlPath: string
resultPath: string
}
export async function saveRedeemArtifacts({
browserContext,
page,
sessionDir,
sessionId,
activityUrl,
code,
finalResult,
attempts,
}: RedeemArtifactsInput): Promise<RedeemArtifactsResult> {
return writeRedeemArtifactFiles({
browserContext,
page,
outputDir: sessionDir,
sessionId,
activityUrl,
code,
finalResult,
attempts,
proofMode: resolveRedeemProofMode(),
})
}
export async function writeRedeemArtifactFiles({
browserContext,
page,
outputDir,
sessionId = '',
activityUrl,
code,
finalResult,
attempts,
proofMode = DEFAULT_REDEEM_PROOF_MODE,
}: WriteRedeemArtifactFilesInput): Promise<RedeemArtifactsResult> {
const effectiveProofMode = resolveRedeemProofMode(proofMode)
const {
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
screenshotPath,
htmlPath,
resultPath,
} = buildArtifactPaths(outputDir)
if (effectiveProofMode === 'off') {
return {
proofMode: effectiveProofMode,
screenshotPath: '',
htmlPath: '',
resultPath: '',
}
}
await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成')
if (effectiveProofMode === 'basic') {
await page.screenshot({ path: screenshotPath, fullPage: true })
await writeRedeemResultFile(resultPath, {
proofMode: effectiveProofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
})
return {
proofMode: effectiveProofMode,
screenshotPath,
htmlPath: '',
resultPath,
}
}
await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true })
let beijingTimeProof: {
screenshotPath: string
pageUrl: string
error?: string
} = {
screenshotPath: '',
pageUrl: BAIDU_BEIJING_TIME_URL,
error: '',
}
if (shouldCaptureBeijingTimeProof(finalResult)) {
try {
beijingTimeProof = await captureBeijingTimeProof(browserContext, {
screenshotPath: beijingTimeScreenshotPath,
})
await composeProofScreenshot(browserContext, {
outputPath: screenshotPath,
redeemImagePath: redeemPageScreenshotPath,
timeImagePath: beijingTimeProof.screenshotPath,
redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'),
timePageUrl: beijingTimeProof.pageUrl,
})
} catch (error) {
beijingTimeProof = {
screenshotPath: '',
pageUrl: BAIDU_BEIJING_TIME_URL,
error: error instanceof Error ? error.message : '北京时间截图生成失败',
}
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
}
} else {
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
}
await fs.writeFile(htmlPath, await page.content(), 'utf8')
await writeRedeemResultFile(resultPath, {
proofMode: effectiveProofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath,
redeemPageScreenshotPath,
beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '',
beijingTimePageUrl: beijingTimeProof.pageUrl,
beijingTimeError: beijingTimeProof.error || '',
})
return {
proofMode: effectiveProofMode,
screenshotPath,
htmlPath,
resultPath,
}
}
export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof }
@@ -1,240 +0,0 @@
/// <reference lib="dom" />
import fs from 'node:fs/promises'
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
const QQ_QR_TARGET_SIZE = 144
type LocatorLike = {
count: () => Promise<number>
click: (options?: { timeout?: number }) => Promise<void>
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
screenshot: (options: { path: string }) => Promise<void>
first: () => LocatorLike
}
type FrameLike = {
url: () => string
locator: (selector: string) => LocatorLike
waitForTimeout: (milliseconds: number) => Promise<void>
evaluate: <T = any>(callback: any) => Promise<T>
}
type PageLike = {
frames: () => FrameLike[]
waitForTimeout: (milliseconds: number) => Promise<void>
locator: (selector: string) => LocatorLike
context: () => {
request: {
get: (url: string, options?: any) => Promise<{
ok: () => boolean
status: () => number
body: () => Promise<Buffer>
}>
}
}
}
type QqQrState = {
visible: boolean
qrVisible?: boolean
scanned: boolean
expired: boolean
message: string
frameUrl?: string
}
export async function ensureQqLoginReady(page: PageLike): Promise<void> {
try {
const frame = await waitForFrame<FrameLike>(
page,
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
10_000,
)
const switcher = frame.locator('#switcher_qlogin')
if (!(await switcher.count())) {
return
}
await switcher.click({ timeout: 3_000 }).catch(() => null)
await frame.waitForTimeout(250)
} catch {
// ignore qq inner tab switch failures; the screenshot retry path will try again
}
}
export async function captureQqQrImage(page: PageLike, qrImagePath: string): Promise<void> {
const frame = await waitForFrame<FrameLike>(
page,
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
10_000,
)
const qrUrl = await resolveQqQrImageUrl(frame)
const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl)
logBrowserSessionDebug('qq.capture.qrUrl', {
qrUrl,
effectiveQrUrl,
frameUrl: frame.url(),
})
if (effectiveQrUrl) {
const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq')
logBrowserSessionDebug('qq.capture.downloadResult', {
qrUrl: effectiveQrUrl,
downloaded: Buffer.isBuffer(body) && body.length > 0,
qrImagePath,
})
if (Buffer.isBuffer(body) && body.length > 0) {
await fs.writeFile(qrImagePath, body)
return
}
}
logBrowserSessionDebug('qq.capture.fallbackScreenshot', {
qrImagePath,
})
const qrLocator = await findQqQrLocator(page, frame)
try {
await qrLocator.screenshot({ path: qrImagePath })
return
} catch {
const iframeLocator = page.locator('#milo-qcwx-frame-qc').first()
await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 })
await iframeLocator.screenshot({ path: qrImagePath })
}
}
export async function findQqQrLocator(page: PageLike, frame: FrameLike | null = null): Promise<LocatorLike> {
const effectiveFrame =
frame ||
await waitForFrame<FrameLike>(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
const locator = effectiveFrame.locator('#qrlogin_img')
await locator.waitFor({ state: 'visible', timeout: 15_000 })
logBrowserSessionDebug('qq.findQrLocator.visibleReady', {
frameUrl: effectiveFrame.url(),
})
return locator
}
export async function extractQqQrState(page: Pick<PageLike, 'frames'>): Promise<QqQrState> {
const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
if (!frame) {
return {
visible: false,
scanned: false,
expired: false,
message: '',
}
}
try {
const { qrVisible, bodyText } = await frame
.evaluate(() => {
const isVisible = (element: Element | null) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
}
const qrImage = document.querySelector('#qrlogin_img')
const bodyText = String(document.body?.textContent || '').replace(/\s+/g, ' ').trim()
return {
qrVisible:
qrImage instanceof HTMLImageElement &&
isVisible(qrImage) &&
Number(qrImage.naturalWidth || 0) > 0 &&
Number(qrImage.naturalHeight || 0) > 0,
bodyText,
}
})
.catch(() => ({
qrVisible: false,
bodyText: '',
}))
return {
visible: true,
qrVisible,
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
message: bodyText.slice(0, 200),
frameUrl: frame.url(),
}
} catch {
return {
visible: true,
qrVisible: false,
scanned: false,
expired: false,
message: '',
frameUrl: frame.url(),
}
}
}
async function resolveQqQrImageUrl(frame: FrameLike): Promise<string> {
return frame
.evaluate(() => {
const qrImage = document.querySelector('#qrlogin_img')
if (!(qrImage instanceof HTMLImageElement)) {
return ''
}
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
})
.then((src) => {
if (!src) {
return ''
}
try {
return new URL(src, frame.url()).toString()
} catch {
return ''
}
})
.catch(() => '')
}
function upgradeQqQrImageUrl(qrUrl: string): string {
if (!qrUrl) {
return ''
}
try {
const url = new URL(qrUrl)
if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) {
return qrUrl
}
const currentSize = Number(url.searchParams.get('d') || 0)
if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) {
url.searchParams.set('d', String(QQ_QR_TARGET_SIZE))
}
return url.toString()
} catch {
return qrUrl
}
}
@@ -1,89 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
classifyTencentRedeemResult,
isCaptchaRejectedResult,
resolveTencentRedeemMessage,
} from './session-redeem.js'
test('resolveTencentRedeemMessage prefers popup detail over generic title', () => {
assert.equal(
resolveTencentRedeemMessage({
popup: {
text: '兑换结果',
detail: '兑换码错误,请确认兑换码信息是否准确。',
},
}),
'兑换码错误,请确认兑换码信息是否准确。',
)
})
test('classifyTencentRedeemResult marks used codes as replacement retries', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
})
assert.equal(result.outcome, 'code_used')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '兑换结果',
detail: '兑换码错误,请确认兑换码信息是否准确。',
},
})
assert.equal(result.outcome, 'code_invalid')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => {
const result = classifyTencentRedeemResult({
iRet: -183,
sMsg: '该CDKEY不存在,请您确认后输入!',
})
assert.equal(result.outcome, 'code_invalid')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult recognizes successful gift popup', () => {
const result = classifyTencentRedeemResult({
popup: {
text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。',
detail: '',
},
})
assert.equal(result.outcome, 'success')
assert.equal(result.success, true)
})
test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => {
assert.equal(
isCaptchaRejectedResult({
iRet: -100,
sMsg: '验证码错误',
}),
true,
)
assert.equal(
isCaptchaRejectedResult({
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
}),
false,
)
})
@@ -1,718 +0,0 @@
/// <reference lib="dom" />
import fs from 'node:fs/promises'
type JsonObject = Record<string, any>
type BrowserResponseLike = {
url: () => string
request: () => { method: () => string }
text: () => Promise<string>
status: () => number
}
type BrowserLocatorLike = {
screenshot: (options: { path: string }) => Promise<void>
getAttribute: (name: string) => Promise<string | null>
click: (options?: JsonObject) => Promise<void>
evaluate: <T = unknown>(callback: (element: Element) => T | Promise<T>) => Promise<T>
}
type BrowserPageLike = {
waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise<unknown>
waitForResponse: (
predicate: (response: BrowserResponseLike) => boolean,
options?: JsonObject,
) => Promise<BrowserResponseLike>
waitForTimeout: (milliseconds: number) => Promise<void>
locator: (selector: string) => BrowserLocatorLike
evaluate: <T = any>(callback: any, arg?: any) => Promise<T>
}
type ActivityInfo = {
role?: JsonObject & {
ready?: boolean
area?: string
}
form?: {
cdkeyInputId?: string
verifyInputId?: string
verifyImgId?: string
submitId?: string
}
}
type TencentRedeemPopup = {
visible?: boolean
text?: string
detail?: string
}
type TencentRedeemResult = JsonObject & {
iRet?: number | string
sMsg?: string
msg?: string
popup?: TencentRedeemPopup | null
}
type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed'
type TencentRedeemClassification = {
outcome: TencentRedeemOutcome
success: boolean
retryWithSameCode: boolean
retryWithReplacementCode: boolean
retCode: number | null
message: string
}
type RedeemAttemptRecord = {
attempt: number
verifyCode: string
verifysession: string
ocrSample: unknown
redeem: TencentRedeemResult
classification: TencentRedeemClassification
role: ActivityInfo['role']
}
type TencentBrowserRedeemSession = JsonObject & {
status: string
notice: string
updatedAt: string
lastError?: string
lastRedeem?: JsonObject
page: BrowserPageLike
browserContext: unknown
sessionDir: string
sessionId: string
}
type RunTencentBrowserRedeemOptions = {
session: TencentBrowserRedeemSession
code: string
maxAttempts: number
ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise<unknown>
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise<unknown>
capturePageCaptchaForOcr: (
page: BrowserPageLike,
options: { sessionDir: string; attempt: number },
) => Promise<{
imageBuffer: Buffer
imagePath: string
imageExtension: string
contentType: string
verifyImgId: string
verifysession: string
}>
recognizeTencentCaptcha: (payload: JsonObject) => Promise<JsonObject>
submitRedeemInBrowser: (
page: BrowserPageLike,
payload: { code: string; verifyCode: string },
) => Promise<TencentRedeemResult>
isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean
refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise<void>
persistSessionState: (session: TencentBrowserRedeemSession) => Promise<unknown>
buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject
saveRedeemArtifacts: (payload: JsonObject) => Promise<JsonObject>
activityUrl: string
}
type ActivityInfoProvider = {
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
}
declare global {
interface Window {
Milo?: {
get?: (key: string) => unknown
}
closeDialog?: () => void
}
}
export async function runTencentBrowserRedeem({
session,
code,
maxAttempts,
ensureLoggedInPresentation,
ensureActivityInfoReady,
fillRedeemCodeInBrowser,
capturePageCaptchaForOcr,
recognizeTencentCaptcha,
submitRedeemInBrowser,
isCaptchaRejectedResult,
refreshPageCaptcha,
persistSessionState,
buildSessionPayload,
saveRedeemArtifacts,
activityUrl,
}: RunTencentBrowserRedeemOptions): Promise<JsonObject> {
session.status = 'redeeming'
session.notice = '正在识别验证码并提交兑换'
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
const attempts: RedeemAttemptRecord[] = []
let finalResult: RedeemAttemptRecord | null = null
try {
await ensureLoggedInPresentation(session, { credentialReady: true })
const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true })
if (!activityInfo?.role?.ready) {
throw new Error('浏览器会话尚未拿到角色信息,请稍后重试')
}
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await fillRedeemCodeInBrowser(session.page, code)
const captcha = await capturePageCaptchaForOcr(session.page, {
sessionDir: session.sessionDir,
attempt,
})
const ocr = await recognizeTencentCaptcha({
imageBase64: captcha.imageBuffer.toString('base64'),
imageExtension: captcha.imageExtension,
imageContentType: captcha.contentType,
finalUrl: captcha.imagePath,
saveSample: true,
tag: `browser-session-${session.sessionId}-attempt-${attempt}`,
})
if (ocr?.code !== 0) {
throw new Error(ocr?.msg || 'OCR 识别失败')
}
const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim()
if (!verifyCode) {
throw new Error('OCR 没有识别出验证码')
}
const redeem = await submitRedeemInBrowser(session.page, {
code,
verifyCode,
})
const classification = classifyTencentRedeemResult(redeem)
const attemptRecord = {
attempt,
verifyCode,
verifysession: captcha.verifysession,
ocrSample: ocr?.data?.saved || null,
redeem,
classification,
role: activityInfo.role,
}
attempts.push(attemptRecord)
finalResult = attemptRecord
if (!isCaptchaRejectedResult(redeem)) {
break
}
await dismissRedeemRetryPopup(session.page)
await refreshPageCaptcha(session.page, captcha.verifyImgId)
}
if (!finalResult) {
throw new Error('浏览器会话兑换没有拿到结果')
}
const artifacts = await saveRedeemArtifacts({
browserContext: session.browserContext,
page: session.page,
sessionDir: session.sessionDir,
sessionId: session.sessionId,
activityUrl,
code,
finalResult,
attempts,
})
session.lastRedeem = {
code,
area: finalResult?.role?.area || '',
attempts,
final: finalResult,
proofMode: artifacts.proofMode,
screenshotPath: artifacts.screenshotPath,
htmlPath: artifacts.htmlPath,
resultPath: artifacts.resultPath,
finishedAt: new Date().toISOString(),
}
session.status = 'redeemed'
session.notice = resolveTencentRedeemMessage(finalResult?.redeem)
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
return {
...buildSessionPayload(session),
redeem: session.lastRedeem,
}
} catch (error) {
session.status = 'failed'
session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败'
session.lastError = session.notice
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
throw error
}
}
export async function capturePageCaptchaForOcr(
page: BrowserPageLike,
{ sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider,
) {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 })
if (!activityInfo?.form?.verifyImgId) {
throw new Error('页面里没有找到验证码图片节点')
}
const verifySelector = `#${activityInfo.form.verifyImgId}`
await page.waitForFunction(
(selector) => {
const element = document.querySelector(selector)
if (!(element instanceof HTMLImageElement)) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.naturalWidth > 0
)
},
verifySelector,
{ timeout: 8_000 },
)
const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png`
await page.locator(verifySelector).screenshot({ path: imagePath })
const verifysession = await page.evaluate(() => {
try {
if (window.Milo && typeof window.Milo.get === 'function') {
return String(window.Milo.get('verifysession') || '')
}
} catch {
// ignore Milo access failures
}
return ''
})
return {
imageBuffer: await fs.readFile(imagePath),
imagePath,
imageExtension: '.png',
contentType: 'image/png',
verifyImgId: activityInfo.form.verifyImgId,
verifysession,
}
}
export async function submitRedeemInBrowser(
page: BrowserPageLike,
payload: { code: string; verifyCode: string },
{ ensureActivityInfoReady }: ActivityInfoProvider,
): Promise<TencentRedeemResult> {
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 })
if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) {
throw new Error('页面里没有找到兑换输入框或提交按钮')
}
const verifySelector = `#${activityInfo.form.verifyInputId}`
const submitSelector = `#${activityInfo.form.submitId}`
await setFormControlValue(page, verifySelector, payload.verifyCode)
await resetRedeemPopup(page)
await normalizeRedeemOverlay(page)
const responsePromise = page
.waitForResponse(
(response) =>
response.url().includes('dfm.ams.game.qq.com/ide/') &&
response.request().method().toUpperCase() === 'POST',
{ timeout: 10_000 },
)
.catch(() => null)
const popupPromise = waitForRedeemPopup(page).catch(() => null)
await clickRedeemSubmit(page, submitSelector)
const networkResponse = await responsePromise
const popup = await popupPromise
const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null
if (parsedNetwork && typeof parsedNetwork === 'object') {
return {
httpStatus: networkResponse.status(),
popup: popup || null,
...parsedNetwork,
}
}
if (!popup) {
throw new Error('页面内兑换没有等到结果弹窗或接口响应')
}
return {
httpStatus: networkResponse?.status?.() || 0,
iRet: inferRedeemCodeFromPopup(popup),
sMsg: popup.text || popup.detail || '兑换完成',
popup,
}
}
export function fillRedeemCodeInBrowser(
page: BrowserPageLike,
code: string,
{ activityInfo }: { activityInfo?: ActivityInfo } = {},
) {
const cdkeySelector = activityInfo?.form?.cdkeyInputId
? `#${activityInfo.form.cdkeyInputId}`
: '[id^="milo_cdkeyInfo_"]'
const verifySelector = activityInfo?.form?.verifyInputId
? `#${activityInfo.form.verifyInputId}`
: '[id^="milo_verifyInput_"]'
return Promise.all([
setFormControlValue(page, cdkeySelector, code),
setFormControlValue(page, verifySelector, ''),
])
}
export async function prepareRedeemCodeFill(
page: BrowserPageLike,
code: string,
{ ensureActivityInfoReady }: ActivityInfoProvider,
) {
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
await fillRedeemCodeInBrowser(page, code, { activityInfo })
}
export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) {
if (!verifyImgId) {
return
}
const selector = `#${verifyImgId}`
const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '')
await page.locator(selector).click({ timeout: 3_000 }).catch(() => null)
await page.waitForFunction(
({ selector: targetSelector, previousSrc }) => {
const element = document.querySelector(targetSelector)
if (!(element instanceof HTMLImageElement)) {
return false
}
return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '')
},
{ selector, previousSrc: currentSrc || '' },
{ timeout: 5_000 },
).catch(() => null)
}
export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean {
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
return true
}
return false
}
export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification {
const retCode = Number(result?.iRet)
const message = resolveTencentRedeemMessage(result)
const normalized = normalizeTencentRedeemText(message)
if (Number.isFinite(retCode) && retCode === -100) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message)
}
if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) {
return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用')
}
if (
retCode === -165 ||
retCode === -183 ||
/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized)
) {
return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确')
}
if (/验证码|校验码/.test(normalized)) {
return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试')
}
if (
retCode === 0 ||
/成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized)
) {
return buildTencentRedeemClassification('success', retCode, message || '兑换成功')
}
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
}
function resetRedeemPopup(page: BrowserPageLike) {
return page.evaluate(() => {
const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText')
const popupDetail = document.querySelector('#PopText2')
if (popup instanceof HTMLElement) {
popup.style.display = 'none'
}
if (popupText instanceof HTMLElement) {
popupText.textContent = ''
}
if (popupDetail instanceof HTMLElement) {
popupDetail.classList.add('hide')
popupDetail.textContent = ''
}
})
}
async function dismissRedeemRetryPopup(page: BrowserPageLike) {
await page.evaluate(() => {
try {
if (typeof window.closeDialog === 'function') {
window.closeDialog()
}
} catch {
// ignore page close hook failures
}
const closeButton = document.querySelector('#pop2 .pop_close')
if (closeButton instanceof HTMLElement) {
closeButton.click()
}
const popup = document.querySelector('#pop2')
if (popup instanceof HTMLElement) {
popup.style.display = 'none'
popup.style.visibility = 'hidden'
}
})
await normalizeRedeemOverlay(page)
await page.waitForTimeout(150)
}
async function normalizeRedeemOverlay(page: BrowserPageLike) {
await page.evaluate(() => {
const overlayIds = ['_overlay_', 'overlay_mask', 'overlay']
for (const id of overlayIds) {
const element = document.getElementById(id)
if (!(element instanceof HTMLElement)) {
continue
}
element.style.pointerEvents = 'none'
element.style.display = 'none'
element.style.opacity = '0'
}
})
}
async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) {
const locator = page.locator(submitSelector)
try {
await locator.click({ timeout: 3_000 })
return
} catch {
await normalizeRedeemOverlay(page)
}
try {
await locator.click({ timeout: 3_000, force: true })
return
} catch {
await locator.evaluate((element) => {
if (element instanceof HTMLElement) {
element.click()
}
})
}
}
async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) {
const nextValue = String(value ?? '')
const appliedValue = await page.evaluate(
({ targetSelector, targetValue }) => {
const isVisible = (element: Element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
element.getClientRects().length > 0
)
}
const matches = [...document.querySelectorAll(targetSelector)]
const element =
matches.find(
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden' &&
isVisible(node),
) ||
matches.find(
(node) =>
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
!node.disabled &&
node.type !== 'hidden',
) ||
null
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
return {
found: false,
value: '',
}
}
const prototype =
element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
if (descriptor?.set) {
descriptor.set.call(element, targetValue)
} else {
element.value = targetValue
}
element.setAttribute('value', targetValue)
element.focus()
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }))
element.blur()
return {
found: true,
value: String(element.value || ''),
}
},
{ targetSelector: selector, targetValue: nextValue },
)
if (!appliedValue?.found) {
throw new Error(`页面里没有找到可填写的表单节点: ${selector}`)
}
if (String(appliedValue.value || '') !== nextValue) {
throw new Error(`页面表单写值失败: ${selector}`)
}
}
async function waitForRedeemPopup(page: BrowserPageLike): Promise<TencentRedeemPopup> {
await page.waitForFunction(() => {
const popup = document.querySelector('#pop2')
const popupText = document.querySelector('#PopText')
if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(popup)
return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0
}, { timeout: 10_000 })
return page.evaluate(() => ({
visible: true,
text: String(document.querySelector('#PopText')?.textContent || '').trim(),
detail: String(document.querySelector('#PopText2')?.textContent || '').trim(),
}))
}
function tryParseJson(text: string): any {
try {
return JSON.parse(text)
} catch {
return null
}
}
function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) {
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
if (!text) {
return 1
}
if (/验证码|校验码/.test(text)) {
return -100
}
if (/成功|已兑换|领取成功/.test(text)) {
return 0
}
return 1
}
export function resolveTencentRedeemMessage(result: TencentRedeemResult): string {
const candidates = [
result?.popup?.detail,
result?.popup?.text,
result?.sMsg,
result?.msg,
]
.map((item) => String(item || '').trim())
.filter(Boolean)
return candidates[0] || '兑换完成'
}
function buildTencentRedeemClassification(
outcome: TencentRedeemOutcome,
retCode: number,
message: string,
): TencentRedeemClassification {
return {
outcome,
success: outcome === 'success',
retryWithSameCode: outcome === 'captcha_rejected',
retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid',
retCode: Number.isFinite(retCode) ? retCode : null,
message: String(message || '').trim(),
}
}
function normalizeTencentRedeemText(value: unknown): string {
return String(value || '')
.replace(/\s+/g, '')
.trim()
.toLowerCase()
}
@@ -1,68 +0,0 @@
import path from 'node:path'
import { logBrowserSessionDebug } from './session-login-shared.js'
type BrowserPageLike = {
screenshot: (options: { path: string; fullPage?: boolean }) => Promise<unknown>
}
type ReviewSession = {
sessionId: string
loginType: string
sessionDir: string
page: BrowserPageLike
lastReview?: {
screenshotPath?: string
signature?: string
[key: string]: unknown
} | null
}
type ActivityInfoForReview = {
role?: {
roleId?: string
roleName?: string
}
}
export function resolveReviewSignature(session: Pick<ReviewSession, 'loginType'>, activityInfo: ActivityInfoForReview) {
const roleId = String(activityInfo?.role?.roleId || '').trim()
const roleName = String(activityInfo?.role?.roleName || '').trim()
return {
roleId,
roleName,
signature: `${session.loginType}|${roleId}|${roleName}`,
}
}
export async function ensureReviewScreenshot(session: ReviewSession, activityInfo: ActivityInfoForReview): Promise<boolean> {
const { roleId, roleName, signature } = resolveReviewSignature(session, activityInfo)
if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) {
return false
}
const screenshotPath = path.join(session.sessionDir, 'role-review.png')
try {
await session.page.screenshot({
path: screenshotPath,
fullPage: true,
})
session.lastReview = {
screenshotPath,
capturedAt: new Date().toISOString(),
roleId,
roleName,
signature,
}
return true
} catch (error) {
logBrowserSessionDebug('reviewScreenshot.capture.error', {
sessionId: session.sessionId,
message: error instanceof Error ? error.message : String(error),
})
return false
}
}
@@ -1,11 +0,0 @@
import path from 'node:path'
import { runtimeConfig } from '../../config/runtime.js'
import { resolveDefaultViewport } from './session-browser-config.js'
export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions')
export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution)
export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -1,102 +0,0 @@
import { chromium } from 'playwright'
import { runtimeConfig } from '../../config/runtime.js'
import {
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
} from './session-browser-config.js'
import { CHROME_PATH } from './session-service-config.js'
type BrowserLike = {
on: (event: 'disconnected', listener: () => void) => void
close: () => Promise<void>
newContext: (options?: Record<string, unknown>) => Promise<any>
}
type SessionServiceErrorOptions = {
statusCode?: number
errorCode?: string
}
let browserPromise: Promise<BrowserLike> | null = null
export class SessionServiceError extends Error {
statusCode: number
errorCode: string
constructor(message: string, { statusCode = 500, errorCode = 'session_error' }: SessionServiceErrorOptions = {}) {
super(message)
this.name = 'SessionServiceError'
this.statusCode = statusCode
this.errorCode = errorCode
}
}
export async function warmupTencentBrowser() {
const browserLaunch = resolveBrowserLaunchOptions()
if (!browserLaunch.prewarm) {
return {
warmed: false,
reason: 'disabled',
browserLaunch,
}
}
await ensureBrowser()
return {
warmed: true,
reason: 'ready',
browserLaunch,
}
}
export async function ensureBrowser(): Promise<BrowserLike> {
if (!browserPromise) {
const launchOptions = resolveBrowserLaunchOptions()
browserPromise = (chromium.launch(buildChromiumLaunchOptions(launchOptions)) as Promise<BrowserLike>)
.then((browser) => {
browser.on('disconnected', () => {
browserPromise = null
})
return browser
})
.catch((error) => {
browserPromise = null
throw error
})
}
return browserPromise
}
export function resolveBrowserLaunchOptions() {
return resolveBrowserLaunchOptionsBase({
browserConfig: runtimeConfig.browser,
chromePath: CHROME_PATH,
})
}
export async function closeBrowserIfIdle({ activeSessionCount = 0 }: { activeSessionCount?: number } = {}): Promise<void> {
if (activeSessionCount > 0 || !browserPromise) {
return
}
if (resolveBrowserLaunchOptions().keepAlive) {
return
}
try {
const browser = await browserPromise
await browser.close()
} catch {
// ignore browser close failures
} finally {
browserPromise = null
}
}
function buildChromiumLaunchOptions(launchOptions: ReturnType<typeof resolveBrowserLaunchOptionsBase>) {
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
}
@@ -1,205 +0,0 @@
import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import {
BROWSER_SESSION_DATA_ROOT,
REDEEM_SUCCESS_AUTO_CLOSE_MS,
} from './session-service-config.js'
import { SessionServiceError } from './session-service-runtime.js'
import {
resolveInitialQrSessionState,
SESSION_TTL_MS,
} from './session-state.js'
type BrowserContextLike = {
close?: () => Promise<void>
}
export type TencentBrowserSession = Record<string, any> & {
sessionId: string
loginType: string
status: string
notice: string
createdAt: string
updatedAt: string
expiresAt: number
autoCloseAt: number
autoCloseTimer: ReturnType<typeof setTimeout> | null
sessionDir: string
browserContext: any
page: any
browserContextClosed?: boolean
presentationSyncAttempts: number
}
type RegisterSessionInput = {
loginType: string
browserContext: BrowserContextLike
page: unknown
}
type CloseSessionDependency = {
closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise<void>
}
type CloseSessionOptions = {
markClosed: boolean
persistSessionState: (session: TencentBrowserSession) => Promise<unknown>
closeBrowserIfIdle: () => Promise<unknown>
}
type PersistSessionStateOptions = {
buildSessionPayload: (session: TencentBrowserSession) => unknown
}
const sessions = new Map<string, TencentBrowserSession>()
export async function registerTencentBrowserSession({
loginType,
browserContext,
page,
}: RegisterSessionInput): Promise<TencentBrowserSession> {
const sessionId = createSessionId()
const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId)
await fs.mkdir(sessionDir, { recursive: true })
const now = new Date().toISOString()
const session = {
sessionId,
loginType,
status: 'created',
notice: '浏览器会话已创建',
createdAt: now,
updatedAt: now,
expiresAt: Date.now() + SESSION_TTL_MS,
autoCloseAt: 0,
autoCloseTimer: null,
sessionDir,
browserContext,
page,
qrImageBase64: '',
qrImagePath: '',
qrUpdatedAt: '',
lastState: null,
lastRedeem: null,
lastReview: null,
lastError: '',
presentationSyncAttempts: 0,
}
sessions.set(sessionId, session)
return session
}
export function listActiveTencentBrowserSessions(): TencentBrowserSession[] {
return [...sessions.values()]
}
export async function getRequiredSession(sessionId: unknown): Promise<TencentBrowserSession> {
const key = String(sessionId || '').trim()
if (!key) {
throw new SessionServiceError('缺少 sessionId', {
statusCode: 400,
errorCode: 'missing_session_id',
})
}
const session = sessions.get(key)
if (!session) {
throw new SessionServiceError('浏览器会话不存在或已过期', {
statusCode: 404,
errorCode: 'session_not_found',
})
}
return session
}
export function applyInitialQrSessionState(session: TencentBrowserSession): void {
resetTencentSessionAutoClose(session)
Object.assign(session, resolveInitialQrSessionState(session.loginType))
}
export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void {
resetTencentSessionAutoClose(session)
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
session.autoCloseAt = autoCloseAt
session.expiresAt = autoCloseAt
session.autoCloseTimer = setTimeout(() => {
void closeSession(session, { markClosed: true }).catch(() => {
// ignore delayed auto-close failures
})
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
}
export function resetTencentSessionAutoClose(session?: TencentBrowserSession | null): void {
if (session?.autoCloseTimer) {
clearTimeout(session.autoCloseTimer)
session.autoCloseTimer = null
}
if (session) {
session.autoCloseAt = 0
}
}
export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise<void> {
const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now())
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
}
export async function closeSession(
session: TencentBrowserSession,
{
markClosed,
persistSessionState,
closeBrowserIfIdle,
}: CloseSessionOptions,
): Promise<void> {
resetTencentSessionAutoClose(session)
sessions.delete(session.sessionId)
try {
await session.browserContext?.close()
} catch {
// ignore browser context close failures
}
session.browserContextClosed = true
session.updatedAt = new Date().toISOString()
if (markClosed) {
session.status = 'closed'
session.notice = '浏览器会话已关闭'
await persistSessionState(session)
}
await closeBrowserIfIdle()
}
export async function persistSessionState(
session: TencentBrowserSession,
{ buildSessionPayload }: PersistSessionStateOptions,
): Promise<void> {
await fs.mkdir(session.sessionDir, { recursive: true })
await fs.writeFile(
path.join(session.sessionDir, 'session.json'),
JSON.stringify(buildSessionPayload(session), null, 2),
'utf8',
)
}
function createSessionId(): string {
let sessionId = ''
do {
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
} while (sessions.has(sessionId))
return sessionId
}
@@ -1,137 +0,0 @@
import { getLoginTypeLabel, type LoginType } from './session-browser-config.js'
export const SESSION_TTL_MS = 30 * 60 * 1000
type InitialQrSessionStateOptions = {
updatedAt?: string
nowMs?: number
sessionTtlMs?: number
loginTypeLabel?: string
}
type HostState = {
loginText?: string
unloginVisible?: boolean
loginedVisible?: boolean
}
type QrState = {
expired?: boolean
scanned?: boolean
}
type ActivityInfo = {
role?: {
ready?: boolean
roleName?: string
}
}
type SessionStatusInput = {
hostState: HostState
qrState: QrState
credentialReady?: boolean
activityInfo?: ActivityInfo | null
}
type SessionNoticeInput = SessionStatusInput & {
loginType: LoginType | string
}
type CookieMap = {
openid?: string
access_token?: string
appid?: string
}
export function resolveInitialQrSessionState(
loginType: LoginType | string,
{
updatedAt = new Date().toISOString(),
nowMs = Date.now(),
sessionTtlMs = SESSION_TTL_MS,
loginTypeLabel = getLoginTypeLabel(loginType),
}: InitialQrSessionStateOptions = {},
) {
return {
status: 'waiting_scan',
notice: `请使用${loginTypeLabel}扫码登录`,
updatedAt,
expiresAt: nowMs + sessionTtlMs,
}
}
export function resolveTencentSessionExpiresAt(
session: { autoCloseAt?: number | string } | null | undefined,
nowMs = Date.now(),
sessionTtlMs = SESSION_TTL_MS,
): number {
const autoCloseAt = Number(session?.autoCloseAt || 0)
if (autoCloseAt > 0) {
return autoCloseAt
}
return nowMs + sessionTtlMs
}
export function shouldRefreshLoggedInPresentation(hostState: HostState): boolean {
const loginText = String(hostState?.loginText || '').trim()
return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText)
}
export function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }: SessionStatusInput): string {
if (hostState.loginedVisible || credentialReady) {
if (credentialReady && activityInfo?.role?.ready) {
return 'ready_to_redeem'
}
return 'logged_in'
}
if (qrState.expired) {
return 'expired'
}
if (qrState.scanned) {
return 'scanned'
}
return 'waiting_scan'
}
export function resolveSessionNotice({
hostState,
qrState,
credentialReady,
activityInfo,
loginType,
}: SessionNoticeInput): string {
const loginTypeLabel = getLoginTypeLabel(loginType)
if (credentialReady) {
if (activityInfo?.role?.ready && activityInfo.role.roleName) {
return `登录已完成,当前角色 ${activityInfo.role.roleName}`
}
return '登录已完成,正在同步角色信息'
}
if (hostState.loginedVisible) {
return '页面已识别登录态'
}
if (qrState.expired) {
return '二维码已失效,准备刷新'
}
if (qrState.scanned) {
return '二维码已扫描,请在手机上确认登录'
}
return `请使用${loginTypeLabel}扫码登录`
}
export function hasRedeemCredential(cookieMap: CookieMap): boolean {
return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid)
}
@@ -1,377 +0,0 @@
/// <reference lib="dom" />
import fs from 'node:fs/promises'
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
type LocatorLike = {
first: () => LocatorLike
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
screenshot: (options: { path: string }) => Promise<void>
isVisible: () => Promise<boolean>
click: (options?: { timeout?: number }) => Promise<void>
evaluate: <T = unknown>(callback: (element: Element) => T | Promise<T>) => Promise<T>
}
type FrameLike = {
url: () => string
locator: (selector: string) => LocatorLike
waitForTimeout: (milliseconds: number) => Promise<void>
evaluate: <T = any>(callback: any) => Promise<T>
}
type PageLike = {
frames: () => FrameLike[]
waitForTimeout: (milliseconds: number) => Promise<void>
context: () => {
request: {
get: (url: string, options?: any) => Promise<{
ok: () => boolean
status: () => number
body: () => Promise<Buffer>
}>
}
}
}
type WxLoginViewState = {
qrVisible: boolean
quickLoginVisible: boolean
switchToNormalVisible: boolean
qrCandidates: Array<{
index: number
visible: boolean
src: string
naturalWidth: number
}>
bodyText: string
}
type WxQrState = {
visible: boolean
qrVisible?: boolean
scanned: boolean
expired: boolean
message: string
frameUrl?: string
}
export async function ensureWxLoginReady(page: PageLike): Promise<void> {
try {
const frame = await waitForFrame<FrameLike>(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
10_000,
)
for (let attempt = 1; attempt <= 12; attempt += 1) {
const viewState = await inspectWxLoginView(frame)
logBrowserSessionDebug('wx.ensureQrMode.viewState', {
attempt,
frameUrl: frame.url(),
qrVisible: viewState.qrVisible,
quickLoginVisible: viewState.quickLoginVisible,
switchToNormalVisible: viewState.switchToNormalVisible,
qrCandidates: viewState.qrCandidates,
bodyText: viewState.bodyText.slice(0, 160),
})
if (viewState.qrVisible) {
return
}
if (
viewState.quickLoginVisible ||
viewState.switchToNormalVisible ||
/使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText)
) {
const switched = await switchWxQuickLoginToQr(frame)
logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', {
attempt,
switched,
})
if (switched) {
await frame.waitForTimeout(700)
continue
}
}
await frame.waitForTimeout(400)
}
} catch {
// ignore wx quick-login switch failures; the screenshot retry path will try again
}
}
export async function captureWxQrImage(page: PageLike, qrImagePath: string): Promise<void> {
const qrLocator = await findWxQrLocator(page)
const frame = await waitForFrame<FrameLike>(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
10_000,
)
const qrUrl = await resolveWxQrImageUrl(frame)
logBrowserSessionDebug('wx.capture.qrUrl', {
qrUrl,
frameUrl: frame.url(),
})
if (qrUrl) {
const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx')
logBrowserSessionDebug('wx.capture.downloadResult', {
qrUrl,
downloaded: Buffer.isBuffer(downloaded) && downloaded.length > 0,
qrImagePath,
})
if (Buffer.isBuffer(downloaded) && downloaded.length > 0) {
await fs.writeFile(qrImagePath, downloaded)
return
}
}
logBrowserSessionDebug('wx.capture.fallbackScreenshot', {
qrImagePath,
})
await qrLocator.screenshot({ path: qrImagePath })
}
export async function findWxQrLocator(page: PageLike): Promise<LocatorLike> {
await ensureWxLoginReady(page)
const frame = await waitForFrame<FrameLike>(
page,
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
)
const viewState = await inspectWxLoginView(frame)
logBrowserSessionDebug('wx.findQrLocator.beforeWait', {
frameUrl: frame.url(),
qrVisible: viewState.qrVisible,
qrCandidates: viewState.qrCandidates,
bodyText: viewState.bodyText.slice(0, 160),
})
const locator = frame.locator('.js_qrcode_img:visible').first()
await locator.waitFor({ state: 'visible', timeout: 15_000 })
logBrowserSessionDebug('wx.findQrLocator.visibleReady', {
frameUrl: frame.url(),
})
return locator
}
export async function extractWxQrState(page: Pick<PageLike, 'frames'>): Promise<WxQrState> {
const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'))
if (!frame) {
return {
visible: false,
scanned: false,
expired: false,
message: '',
}
}
try {
const viewState = await inspectWxLoginView(frame)
const qrVisible = viewState.qrVisible
const bodyText = viewState.bodyText
return {
visible: true,
qrVisible,
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
message: bodyText.slice(0, 200),
frameUrl: frame.url(),
}
} catch {
return {
visible: true,
qrVisible: false,
scanned: false,
expired: false,
message: '',
frameUrl: frame.url(),
}
}
}
async function inspectWxLoginView(frame: FrameLike): Promise<WxLoginViewState> {
return frame
.evaluate(() => {
const isVisible = (element: Element | null) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
}
const quickLogin = document.querySelector('.js_quick_login')
const switchToNormal = document.querySelector('.js_switchToNormal')
const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => {
if (!(item instanceof HTMLImageElement)) {
return {
index,
visible: false,
src: '',
naturalWidth: 0,
}
}
return {
index,
visible: isVisible(item),
src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(),
naturalWidth: Number(item.naturalWidth || 0),
}
})
const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0)
return {
qrVisible: Boolean(visibleQrCandidate),
quickLoginVisible: isVisible(quickLogin),
switchToNormalVisible: isVisible(switchToNormal),
qrCandidates,
bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(),
}
})
.catch(() => ({
qrVisible: false,
quickLoginVisible: false,
switchToNormalVisible: false,
qrCandidates: [],
bodyText: '',
}))
}
async function switchWxQuickLoginToQr(frame: FrameLike): Promise<boolean> {
const switcherCandidates = [
frame.locator('.js_switchToNormal:visible').first(),
frame.locator('.js_switchToNormal').first(),
frame.locator('button:has-text("使用其他头像、昵称或账号")').first(),
frame.locator('text=使用其他头像、昵称或账号').first(),
]
for (const candidate of switcherCandidates) {
const visible = await candidate.isVisible().catch(() => false)
if (!visible) {
continue
}
logBrowserSessionDebug('wx.switchToNormal.candidateVisible', {
candidateIndex: switcherCandidates.indexOf(candidate),
})
const clicked =
(await candidate
.click({ timeout: 2_000 })
.then(() => true)
.catch(() => false)) ||
(await candidate
.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
element.click()
return true
})
.catch(() => false))
if (clicked) {
logBrowserSessionDebug('wx.switchToNormal.clicked', {
candidateIndex: switcherCandidates.indexOf(candidate),
})
return true
}
}
return frame
.evaluate(() => {
const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => {
if (!(element instanceof HTMLElement)) {
return false
}
const text = String(element.textContent || '').replace(/\s+/g, ' ').trim()
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
/使用其他头像、昵称或账号/.test(text) &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
})
const target = candidates[0]
if (!(target instanceof HTMLElement)) {
return false
}
target.click()
return true
})
.catch(() => false)
}
async function resolveWxQrImageUrl(frame: FrameLike): Promise<string> {
return frame
.evaluate(() => {
const isVisible = (element: Element | null) => {
if (!(element instanceof HTMLElement)) {
return false
}
const style = window.getComputedStyle(element)
const rect = element.getBoundingClientRect()
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0' &&
rect.width > 0 &&
rect.height > 0
)
}
const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img'))
const qrImage =
qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) ||
qrImages.find((item) => item instanceof HTMLImageElement) ||
null
if (!(qrImage instanceof HTMLImageElement)) {
return ''
}
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
})
.then((src) => {
if (!src) {
return ''
}
try {
return new URL(src, frame.url()).toString()
} catch {
return ''
}
})
.catch(() => '')
}
@@ -1,376 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
normalizeLoginType,
parseViewportSpec,
} from './session-browser-config.js'
import { normalizeTencentCookieMap } from './session-cookie-map.js'
import {
createEmptyActivityInfo,
ensureActivityInfoReady,
ensureLoggedInPresentation,
extractActivityInfo,
} from './session-activity.js'
import {
captureQrImageWithRetry,
ensureLoginTab,
} from './session-page.js'
import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js'
import { ensureReviewScreenshot } from './session-review.js'
import { resolveInitialQrSessionState } from './session-state.js'
import {
REDEEM_SUCCESS_AUTO_CLOSE_MS,
resolveTencentSessionExpiresAt,
} from './session.js'
test('resolveTencentSessionExpiresAt uses normal ttl when no auto-close is armed', () => {
const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0)
assert.equal(
resolveTencentSessionExpiresAt({ autoCloseAt: 0 }, nowMs),
nowMs + (30 * 60 * 1000),
)
})
test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once armed', () => {
const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0)
const autoCloseAt = nowMs + REDEEM_SUCCESS_AUTO_CLOSE_MS
assert.equal(
resolveTencentSessionExpiresAt({ autoCloseAt }, nowMs + 60_000),
autoCloseAt,
)
})
test('normalizeLoginType collapses aliases and falls back to qq', () => {
assert.equal(normalizeLoginType(' qc '), 'qq')
assert.equal(normalizeLoginType('wechat'), 'wx')
assert.equal(normalizeLoginType('unknown'), 'qq')
assert.equal(normalizeLoginType(''), 'qq')
})
test('parseViewportSpec accepts valid viewport strings and rejects invalid values', () => {
assert.deepEqual(parseViewportSpec('1920x1080'), { width: 1920, height: 1080 })
assert.deepEqual(parseViewportSpec('1920x1080x24'), { width: 1920, height: 1080 })
assert.equal(parseViewportSpec('799x600'), null)
assert.equal(parseViewportSpec('broken'), null)
})
test('buildRedeemPayload strips internal paths unless explicitly requested', () => {
const redeem = {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
screenshotPath: '/tmp/redeem.png',
htmlPath: '/tmp/redeem.html',
resultPath: '/tmp/redeem.json',
}
assert.deepEqual(buildRedeemPayload(redeem), {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
})
assert.deepEqual(buildRedeemPayload(redeem, { includeInternalPaths: true }), {
code: 'ABC123',
area: '微信区',
proofMode: 'basic',
attempts: [
{
attempt: 1,
redeem: { success: false, message: '验证码错误' },
},
],
final: {
attempt: 2,
redeem: { success: true, message: '兑换成功' },
},
finishedAt: '2026-04-14T09:40:00.000Z',
screenshotPath: '/tmp/redeem.png',
htmlPath: '/tmp/redeem.html',
resultPath: '/tmp/redeem.json',
})
})
test('buildArtifactsPayload only exposes internal artifact paths in debug mode', () => {
const session = {
sessionDir: '/tmp/session',
qrImagePath: '/tmp/session/qq-qr.png',
qrImageBase64: 'base64data',
lastReview: {
screenshotPath: '/tmp/session/review.png',
},
lastRedeem: {
screenshotPath: '/tmp/session/redeem.png',
htmlPath: '/tmp/session/redeem.html',
resultPath: '/tmp/session/redeem.json',
},
}
assert.deepEqual(buildArtifactsPayload(session), {
hasQrImage: true,
hasReviewScreenshot: true,
hasScreenshot: true,
})
assert.deepEqual(buildArtifactsPayload(session, { includeInternalPaths: true }), {
hasQrImage: true,
hasReviewScreenshot: true,
hasScreenshot: true,
sessionDir: '/tmp/session',
qrImagePath: '/tmp/session/qq-qr.png',
screenshotPath: '/tmp/session/redeem.png',
htmlPath: '/tmp/session/redeem.html',
resultPath: '/tmp/session/redeem.json',
reviewScreenshotPath: '/tmp/session/review.png',
})
})
test('normalizeTencentCookieMap fills required defaults and derived proxy cookie', () => {
assert.deepEqual(
normalizeTencentCookieMap([
{ name: 'openid', value: 'openid-1' },
{ name: 'access_token', value: 'token-1' },
{ name: '', value: 'ignored' },
]),
{
openid: 'openid-1',
access_token: 'token-1',
acctype: 'qc',
appid: '101491592',
iegams_milo_proxylogin_qc: '101491592_$$_openid-1_$$_token-1',
},
)
})
test('resolveInitialQrSessionState creates waiting-scan snapshot with ttl', () => {
assert.deepEqual(
resolveInitialQrSessionState('wx', {
updatedAt: '2026-04-14T09:30:00.000Z',
nowMs: 1_700_000_000_000,
sessionTtlMs: 1_800_000,
}),
{
status: 'waiting_scan',
notice: '请使用微信扫码登录',
updatedAt: '2026-04-14T09:30:00.000Z',
expiresAt: 1_700_001_800_000,
},
)
})
test('ensureReviewScreenshot writes screenshot state and skips unchanged signature', async () => {
const screenshotCalls = []
const session = {
sessionId: 'txbs-demo',
loginType: 'qq',
sessionDir: '/tmp/session-demo',
page: {
async screenshot(options) {
screenshotCalls.push(options)
},
},
lastReview: null,
}
const activityInfo = {
role: {
roleId: '1001',
roleName: '测试角色',
},
}
assert.equal(await ensureReviewScreenshot(session, activityInfo), true)
assert.equal(screenshotCalls.length, 1)
assert.equal(session.lastReview.roleId, '1001')
assert.equal(session.lastReview.roleName, '测试角色')
assert.equal(session.lastReview.signature, 'qq|1001|测试角色')
assert.match(session.lastReview.screenshotPath, /role-review\.png$/)
assert.equal(await ensureReviewScreenshot(session, activityInfo), false)
assert.equal(screenshotCalls.length, 1)
})
test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => {
const calls = []
const makeLocator = (name) => ({
async count() {
return 1
},
first() {
const firstLocator = {
async count() {
return 1
},
async click(options) {
calls.push({ type: 'click', name, options })
},
}
return firstLocator
},
})
const page = {
locator(selector) {
if (selector === '#unlogin') {
return makeLocator('loginButton')
}
if (selector === '.qc-tab') {
return makeLocator('qqTab')
}
throw new Error(`unexpected selector: ${selector}`)
},
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
await ensureLoginTab(page, 'qq', {
async ensureQqLoginReady() {
calls.push({ type: 'ensure', name: 'qq' })
},
})
assert.deepEqual(calls, [
{ type: 'click', name: 'loginButton', options: { timeout: 2_000 } },
{ type: 'click', name: 'qqTab', options: { timeout: 2_000 } },
{ type: 'wait', ms: 250 },
{ type: 'ensure', name: 'qq' },
])
})
test('captureQrImageWithRetry retries once after retryable error', async () => {
const calls = []
const page = {
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
let attempt = 0
await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', {
async captureWxQrImage() {
attempt += 1
calls.push({ type: 'capture', attempt })
if (attempt === 1) {
throw new Error('Timeout while waiting for qr')
}
},
async ensureLoginTab(_page, loginType) {
calls.push({ type: 'ensureLoginTab', loginType })
},
isRetryableQrCaptureError(error) {
return String(error.message).includes('Timeout')
},
logBrowserSessionDebug() {},
})
assert.equal(attempt, 2)
assert.deepEqual(calls, [
{ type: 'capture', attempt: 1 },
{ type: 'wait', ms: 500 },
{ type: 'ensureLoginTab', loginType: 'wx' },
{ type: 'capture', attempt: 2 },
])
})
test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => {
const session = {
page: { marker: 'page' },
presentationSyncAttempts: 0,
}
const calls = []
let hostCall = 0
const result = await ensureLoggedInPresentation(session, {
credentialReady: true,
activityUrl: 'https://example.com/activity',
async extractHostState() {
hostCall += 1
return hostCall === 1
? { unloginVisible: true, loginedVisible: false, loginText: '' }
: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }
},
async reloadActivityPageForPresentation(_page, activityUrl) {
calls.push(activityUrl)
},
})
assert.equal(session.presentationSyncAttempts, 1)
assert.deepEqual(calls, ['https://example.com/activity'])
assert.deepEqual(result, {
reloaded: true,
hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' },
})
})
test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => {
const calls = []
const page = {
async waitForTimeout(ms) {
calls.push({ type: 'wait', ms })
},
}
const snapshots = [
{ role: { ready: false, roleName: '' } },
{ role: { ready: true, roleName: '测试角色' } },
]
let nowValue = 0
const result = await ensureActivityInfoReady(page, {
triggerRender: true,
timeoutMs: 1000,
async triggerActivityRoleRender() {
calls.push({ type: 'trigger' })
},
async extractActivityInfo() {
calls.push({ type: 'extract' })
return snapshots.shift()
},
now() {
nowValue += 300
return nowValue
},
})
assert.deepEqual(calls, [
{ type: 'trigger' },
{ type: 'extract' },
{ type: 'wait', ms: 400 },
{ type: 'extract' },
])
assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } })
})
test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => {
const page = {
async evaluate() {
throw new Error('page closed')
},
}
assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo())
})
@@ -1,410 +0,0 @@
import {
getLoginTypeLabel,
normalizeLoginType,
} from './session-browser-config.js'
import { logBrowserSessionDebug } from './session-login-shared.js'
import {
loadCookieMapFromContext,
} from './session-cookie-map.js'
import {
ensureActivityInfoReady,
ensureLoggedInPresentation,
extractActivityInfo,
} from './session-activity.js'
import {
buildSessionPayload as buildSessionPayloadBase,
} from './session-payload.js'
import {
captureSessionQr,
ensureLoginTab,
extractHostState,
extractQrState,
reloadActivityPageForPresentation,
} from './session-page.js'
import {
saveRedeemArtifacts,
} from './session-proof.js'
import {
ensureReviewScreenshot,
} from './session-review.js'
import {
capturePageCaptchaForOcr,
isCaptchaRejectedResult,
prepareRedeemCodeFill,
refreshPageCaptcha,
runTencentBrowserRedeem,
submitRedeemInBrowser,
} from './session-redeem.js'
import {
ensureWxLoginReady,
} from './session-wx.js'
import { recognizeTencentCaptcha } from './ocr.js'
import {
hasRedeemCredential,
resolveInitialQrSessionState,
resolveSessionNotice,
resolveSessionStatus,
resolveTencentSessionExpiresAt,
} from './session-state.js'
import {
ACTIVITY_URL,
DEFAULT_VIEWPORT,
REDEEM_SUCCESS_AUTO_CLOSE_MS,
SESSION_DEBUG_ENABLED,
} from './session-service-config.js'
import {
closeBrowserIfIdle,
ensureBrowser,
resolveBrowserLaunchOptions,
SessionServiceError,
warmupTencentBrowser,
} from './session-service-runtime.js'
import {
applyInitialQrSessionState,
armRedeemedSessionAutoClose,
cleanupExpiredTencentBrowserSessions,
closeSession as closeStoredSession,
getRequiredSession,
listActiveTencentBrowserSessions,
persistSessionState as persistStoredSessionState,
registerTencentBrowserSession,
resetTencentSessionAutoClose,
} from './session-service-store.js'
import type { TencentBrowserSession } from './session-service-store.js'
type JsonObject = Record<string, any>
type IncludeQrImageOptions = {
includeQrImage?: boolean
}
type CloseAllSessionsOptions = {
markClosed?: boolean
}
type CloseManagedSessionOptions = {
markClosed: boolean
}
export { resolveTencentSessionExpiresAt }
export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser }
export async function createTencentBrowserSession(payload: JsonObject = {}): Promise<JsonObject> {
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const loginType = normalizeLoginType(payload.loginType || payload.qrType)
const browser = await ensureBrowser()
const browserContext = await browser.newContext({
viewport: DEFAULT_VIEWPORT,
deviceScaleFactor: 1,
})
const page = await browserContext.newPage()
page.setDefaultTimeout(20_000)
const session = await registerTencentBrowserSession({
loginType,
browserContext,
page,
})
const sessionId = session.sessionId
try {
await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' })
await ensureLoginTab(page, loginType)
await captureSessionQr(session, { ensureLoginReady: false })
applyInitialQrSessionState(session)
await persistSessionState(session)
void refreshTencentBrowserSession(sessionId).catch((error) => {
logBrowserSessionDebug('createSession.backgroundRefresh.error', {
sessionId,
message: error instanceof Error ? error.message : String(error),
})
})
return buildSessionPayload(session)
} catch (error) {
session.status = 'failed'
session.notice = error instanceof Error ? error.message : `${getLoginTypeLabel(loginType)} 浏览器会话初始化失败`
session.lastError = session.notice
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
throw error
}
}
export async function getTencentBrowserSession(
sessionId: unknown,
options: IncludeQrImageOptions = {},
): Promise<JsonObject> {
return refreshTencentBrowserSession(sessionId, options)
}
export async function getTencentBrowserSessionSummary(sessionId: unknown): Promise<JsonObject> {
return refreshTencentBrowserSession(sessionId, { includeQrImage: false })
}
export async function reloadTencentBrowserSession(sessionId: unknown): Promise<JsonObject> {
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const session = await getRequiredSession(sessionId)
if (session.browserContextClosed) {
throw new SessionServiceError('浏览器会话已关闭', {
statusCode: 410,
errorCode: 'session_closed',
})
}
try {
await reloadActivityPageForPresentation(session.page, ACTIVITY_URL)
const cookieMap = await loadCookieMapFromContext(session.browserContext)
const credentialReady = hasRedeemCredential(cookieMap)
if (!credentialReady) {
session.qrImageBase64 = ''
session.qrImagePath = ''
session.qrUpdatedAt = ''
await ensureLoginTab(session.page, session.loginType)
await captureSessionQr(session)
}
session.notice = credentialReady
? '后端页面已刷新,正在同步最新状态'
: `已刷新${getLoginTypeLabel(session.loginType)}登录页,准备重新抓取二维码`
session.updatedAt = new Date().toISOString()
return await refreshTencentBrowserSession(sessionId)
} catch (error) {
session.status = 'failed'
session.notice = error instanceof Error ? error.message : '刷新后端页面失败'
session.lastError = session.notice
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
throw error
}
}
export async function refreshTencentBrowserSession(
sessionId: unknown,
{ includeQrImage = true }: IncludeQrImageOptions = {},
): Promise<JsonObject> {
await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession })
const session = await getRequiredSession(sessionId)
if (session.browserContextClosed) {
throw new SessionServiceError('浏览器会话已关闭', {
statusCode: 410,
errorCode: 'session_closed',
})
}
try {
let hostState = await extractHostState(session.page)
const cookieMap = await loadCookieMapFromContext(session.browserContext)
const credentialReady = hasRedeemCredential(cookieMap)
if (credentialReady) {
const presentation = await ensureLoggedInPresentation(session, {
hostState,
credentialReady,
activityUrl: ACTIVITY_URL,
})
hostState = presentation.hostState
} else {
session.presentationSyncAttempts = 0
}
if (!credentialReady && session.loginType === 'wx') {
await ensureWxLoginReady(session.page)
}
const qrState = await extractQrState(session.page, session.loginType)
const activityInfo = credentialReady
? await ensureActivityInfoReady(session.page, { triggerRender: true })
: await extractActivityInfo(session.page)
session.lastState = {
hostState,
qrState,
credentialReady,
activityInfo,
cookieKeys: Object.keys(cookieMap),
}
session.status = resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo })
session.notice = resolveSessionNotice({
hostState,
qrState,
credentialReady,
activityInfo,
loginType: session.loginType,
})
if (credentialReady && activityInfo?.role?.ready) {
await ensureReviewScreenshot(session, activityInfo)
}
session.updatedAt = new Date().toISOString()
session.expiresAt = resolveTencentSessionExpiresAt(session)
if (!session.qrImageBase64 || session.status === 'expired') {
if (session.status === 'expired') {
await reloadActivityPageForPresentation(session.page, ACTIVITY_URL)
await ensureLoginTab(session.page, session.loginType)
}
await captureSessionQr(session)
if (session.status === 'expired') {
session.notice = '二维码已刷新,请重新扫码'
session.status = 'waiting_scan'
}
}
await persistSessionState(session)
return buildSessionPayload(session, { includeQrImage })
} catch (error) {
session.status = 'failed'
session.notice = error instanceof Error ? error.message : `查询 ${getLoginTypeLabel(session.loginType)} 浏览器会话状态失败`
session.lastError = session.notice
session.updatedAt = new Date().toISOString()
await persistSessionState(session)
return buildSessionPayload(session, { includeQrImage })
}
}
export async function redeemTencentBrowserSession(sessionId: unknown, payload: JsonObject = {}): Promise<JsonObject> {
const session = await getRequiredSession(sessionId)
const code = String(payload.code || payload.sCdKey || '').trim()
const maxAttempts = Math.max(1, Math.min(Number(payload.maxAttempts || 6), 12))
if (!code) {
throw new SessionServiceError('缺少兑换码', {
statusCode: 400,
errorCode: 'missing_redeem_code',
})
}
const refreshed = await refreshTencentBrowserSession(sessionId)
if (!['logged_in', 'ready_to_redeem', 'redeemed'].includes(refreshed.status)) {
throw new SessionServiceError(`当前会话还不能兑换,状态为 ${refreshed.status}`, {
statusCode: 409,
errorCode: 'session_not_ready',
})
}
resetTencentSessionAutoClose(session)
await runTencentBrowserRedeem({
session,
code,
maxAttempts,
ensureLoggedInPresentation: (redeemSession, options) =>
ensureLoggedInPresentation(redeemSession as any, options as any),
ensureActivityInfoReady,
fillRedeemCodeInBrowser,
capturePageCaptchaForOcr: (page, options) =>
capturePageCaptchaForOcr(page, { ...options, ensureActivityInfoReady }),
recognizeTencentCaptcha,
submitRedeemInBrowser: (page, redeemPayload) =>
submitRedeemInBrowser(page, redeemPayload, { ensureActivityInfoReady }),
isCaptchaRejectedResult,
refreshPageCaptcha,
persistSessionState,
buildSessionPayload,
saveRedeemArtifacts,
activityUrl: ACTIVITY_URL,
})
armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession })
await persistSessionState(session)
return {
...buildSessionPayload(session),
redeem: session.lastRedeem,
}
}
export async function closeTencentBrowserSession(sessionId: unknown): Promise<JsonObject> {
const session = await getRequiredSession(sessionId)
await closeManagedSession(session, { markClosed: true })
return {
sessionId,
closed: true,
}
}
export async function closeAllTencentBrowserSessions(
{ markClosed = true }: CloseAllSessionsOptions = {},
): Promise<JsonObject> {
const activeSessions = listActiveTencentBrowserSessions()
await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed })))
return {
closedCount: activeSessions.length,
}
}
export async function getTencentBrowserSessionScreenshotPath(sessionId: unknown): Promise<string> {
const session = await getRequiredSession(sessionId)
const screenshotPath = session.lastRedeem?.screenshotPath
if (!screenshotPath) {
throw new SessionServiceError('当前会话还没有兑换截图', {
statusCode: 404,
errorCode: 'screenshot_not_ready',
})
}
return screenshotPath
}
export async function getTencentBrowserSessionReviewScreenshotPath(sessionId: unknown): Promise<string> {
const session = await getRequiredSession(sessionId)
const screenshotPath = session.lastReview?.screenshotPath
if (!screenshotPath) {
throw new SessionServiceError('当前会话还没有客服复核截图', {
statusCode: 404,
errorCode: 'review_screenshot_not_ready',
})
}
return screenshotPath
}
async function persistSessionState(session: TencentBrowserSession): Promise<void> {
await persistStoredSessionState(session, { buildSessionPayload })
}
function buildSessionPayload(
session: TencentBrowserSession,
{ includeQrImage = true }: IncludeQrImageOptions = {},
): JsonObject {
return buildSessionPayloadBase(session, {
activityUrl: ACTIVITY_URL,
includeQrImage,
sessionDebugEnabled: SESSION_DEBUG_ENABLED,
browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null,
})
}
async function fillRedeemCodeInBrowser(page: any, code: string): Promise<void> {
await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady })
}
async function closeManagedSession(
session: TencentBrowserSession,
{ markClosed }: CloseManagedSessionOptions,
): Promise<void> {
await closeStoredSession(session, {
markClosed,
persistSessionState,
closeBrowserIfIdle: () => closeBrowserIfIdle({
activeSessionCount: listActiveTencentBrowserSessions().length,
}),
})
}