init
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { createAdminAuditLog, listAdminAuditLogs } from '../repositories/admin-audit-log-repo.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export function writeAdminAuditLog(session, payload = {}) {
|
||||
if (!session?.userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createAdminAuditLog({
|
||||
actorUserId: session.userId,
|
||||
actorUsername: session.username,
|
||||
actorRole: session.role,
|
||||
action: String(payload.action || '').trim(),
|
||||
targetType: String(payload.targetType || '').trim() || 'unknown',
|
||||
targetId: String(payload.targetId || '').trim(),
|
||||
payloadJson: JSON.stringify(payload.data || {}),
|
||||
createdAt: nowIso(),
|
||||
})
|
||||
}
|
||||
|
||||
export function getAdminAuditLogs(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listAdminAuditLogs({
|
||||
page,
|
||||
pageSize,
|
||||
actorUsername: String(query.actorUsername || '').trim(),
|
||||
action: String(query.action || '').trim(),
|
||||
targetType: String(query.targetType || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
logId: item.id,
|
||||
actorUserId: item.actor_user_id,
|
||||
actorUsername: item.actor_username,
|
||||
actorRole: item.actor_role,
|
||||
action: item.action,
|
||||
targetType: item.target_type,
|
||||
targetId: item.target_id,
|
||||
payload: safeParseJson(item.payload_json),
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePage(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1
|
||||
}
|
||||
|
||||
function normalizePageSize(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 20
|
||||
}
|
||||
return Math.min(100, Math.floor(parsed))
|
||||
}
|
||||
|
||||
function normalizeDateQuery(rawValue, endOfDay = false) {
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function safeParseJson(value) {
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import {
|
||||
countActiveAdminUsers,
|
||||
createAdminUser,
|
||||
getAdminUserById,
|
||||
getAdminUserByUsername,
|
||||
listAdminUsers,
|
||||
updateAdminUser,
|
||||
} from '../repositories/admin-user-repo.js'
|
||||
import { addHours, nowIso } from '../utils/time.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
|
||||
export async function ensureAdminUsersBootstrapped() {
|
||||
ensureAdminAuthConfigured()
|
||||
|
||||
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : []
|
||||
|
||||
for (const configuredUser of configuredUsers) {
|
||||
const username = String(configuredUser?.username || '').trim()
|
||||
const password = String(configuredUser?.password || '').trim()
|
||||
const role = normalizeAdminRole(configuredUser?.role)
|
||||
|
||||
if (!username || !password) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (getAdminUserByUsername(username)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
createAdminUser({
|
||||
username,
|
||||
passwordHash: hashAdminPassword(password),
|
||||
role,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function loginAdmin(username, password) {
|
||||
ensureAdminAuthConfigured()
|
||||
|
||||
const normalizedUsername = String(username || '').trim()
|
||||
const normalizedPassword = String(password || '').trim()
|
||||
|
||||
if (!normalizedUsername || !normalizedPassword) {
|
||||
throw createHttpError('缺少后台账号或密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_credentials_required',
|
||||
})
|
||||
}
|
||||
|
||||
const user = getAdminUserByUsername(normalizedUsername)
|
||||
if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) {
|
||||
throw createHttpError('账号或密码错误', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_login_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return createAdminSession(user)
|
||||
}
|
||||
|
||||
export function verifyAdminSessionToken(token) {
|
||||
ensureAdminAuthConfigured()
|
||||
|
||||
const normalizedToken = String(token || '').trim()
|
||||
if (!normalizedToken) {
|
||||
throw createHttpError('未登录或登录已失效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_required',
|
||||
})
|
||||
}
|
||||
|
||||
const [encodedPayload, signature] = normalizedToken.split('.')
|
||||
if (!encodedPayload || !signature) {
|
||||
throw createHttpError('后台登录态无效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const expectedSignature = signPayload(encodedPayload)
|
||||
if (!safeCompare(signature, expectedSignature)) {
|
||||
throw createHttpError('后台登录态无效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
let payload = null
|
||||
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
throw createHttpError('后台登录态无效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const expiresAt = String(payload?.exp || '').trim()
|
||||
if (!expiresAt || Date.parse(expiresAt) <= Date.now()) {
|
||||
throw createHttpError('后台登录已过期,请重新登录', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_expired',
|
||||
})
|
||||
}
|
||||
|
||||
const userId = Number(payload?.uid || 0)
|
||||
const user = getAdminUserById(userId)
|
||||
if (!user || user.status !== 'active') {
|
||||
throw createHttpError('后台账号已不可用,请重新登录', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_user_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: String(payload.sid || '').trim(),
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: normalizeAdminRole(user.role),
|
||||
expiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminSessionSummary(token) {
|
||||
const session = verifyAdminSessionToken(token)
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
expiresAt: session.expiresAt,
|
||||
user: {
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdminRole(session, allowedRoles) {
|
||||
if (allowedRoles.includes(session.role)) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('当前账号没有此操作权限', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
export function getAdminUserList(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listAdminUsers({
|
||||
page,
|
||||
pageSize,
|
||||
username: String(query.username || '').trim(),
|
||||
role: normalizeRoleQuery(query.role),
|
||||
status: normalizeStatusQuery(query.status),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminUser),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function createManagedAdminUser(payload = {}) {
|
||||
const username = normalizeUsername(payload.username)
|
||||
const password = normalizePassword(payload.password)
|
||||
|
||||
if (!username) {
|
||||
throw createHttpError('缺少后台账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_username_required',
|
||||
})
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw createHttpError('缺少后台密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_password_required',
|
||||
})
|
||||
}
|
||||
|
||||
validateUsername(username)
|
||||
validatePassword(password)
|
||||
|
||||
if (getAdminUserByUsername(username)) {
|
||||
throw createHttpError('后台账号已存在', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_user_exists',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const created = createAdminUser({
|
||||
username,
|
||||
passwordHash: hashAdminPassword(password),
|
||||
role: normalizeAdminRole(payload.role),
|
||||
status: normalizeAdminUserStatus(payload.status || 'active'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
user: mapAdminUser(created),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateManagedAdminUserRole(userId, payload = {}, session) {
|
||||
const user = getRequiredAdminUser(userId)
|
||||
const role = normalizeAdminRole(payload.role)
|
||||
|
||||
if (user.role === role) {
|
||||
return {
|
||||
user: mapAdminUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
ensureAdminUserChangeAllowed(user, { nextRole: role }, session)
|
||||
const updated = updateAdminUser(user.id, {
|
||||
role,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateManagedAdminUserStatus(userId, payload = {}, session) {
|
||||
const user = getRequiredAdminUser(userId)
|
||||
const status = normalizeAdminUserStatus(payload.status)
|
||||
|
||||
if (user.status === status) {
|
||||
return {
|
||||
user: mapAdminUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
ensureAdminUserChangeAllowed(user, { nextStatus: status }, session)
|
||||
const updated = updateAdminUser(user.id, {
|
||||
status,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function resetManagedAdminUserPassword(userId, payload = {}) {
|
||||
const user = getRequiredAdminUser(userId)
|
||||
const password = normalizePassword(payload.password)
|
||||
|
||||
if (!password) {
|
||||
throw createHttpError('缺少新密码', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_password_required',
|
||||
})
|
||||
}
|
||||
|
||||
validatePassword(password)
|
||||
const updated = updateAdminUser(user.id, {
|
||||
password_hash: hashAdminPassword(password),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureAdminAuthConfigured() {
|
||||
const sessionSecret = String(runtimeConfig.admin?.sessionSecret || '').trim()
|
||||
const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : []
|
||||
|
||||
if (sessionSecret && configuredUsers.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('后台鉴权未配置,请先设置 admin.sessionSecret 和 admin.defaultUsers', {
|
||||
statusCode: 503,
|
||||
errorCode: 'admin_auth_not_configured',
|
||||
})
|
||||
}
|
||||
|
||||
function createAdminSession(user) {
|
||||
const issuedAt = nowIso()
|
||||
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12))
|
||||
const payload = {
|
||||
sid: crypto.randomBytes(12).toString('hex'),
|
||||
uid: user.id,
|
||||
usr: user.username,
|
||||
role: normalizeAdminRole(user.role),
|
||||
iat: issuedAt,
|
||||
exp: expiresAt,
|
||||
}
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const signature = signPayload(encodedPayload)
|
||||
|
||||
return {
|
||||
token: `${encodedPayload}.${signature}`,
|
||||
expiresAt,
|
||||
user: {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: normalizeAdminRole(user.role),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function hashAdminPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
const derived = crypto.scryptSync(password, salt, 64).toString('hex')
|
||||
return `scrypt$${salt}$${derived}`
|
||||
}
|
||||
|
||||
function verifyAdminPassword(password, storedHash) {
|
||||
const [algorithm, salt, expectedHash] = String(storedHash || '').split('$')
|
||||
if (algorithm !== 'scrypt' || !salt || !expectedHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
const actualHash = crypto.scryptSync(password, salt, 64).toString('hex')
|
||||
return safeCompare(actualHash, expectedHash)
|
||||
}
|
||||
|
||||
function signPayload(encodedPayload) {
|
||||
return crypto
|
||||
.createHmac('sha256', String(runtimeConfig.admin.sessionSecret || ''))
|
||||
.update(encodedPayload)
|
||||
.digest('base64url')
|
||||
}
|
||||
|
||||
export function normalizeAdminRole(role) {
|
||||
return String(role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'operator'
|
||||
}
|
||||
|
||||
export function normalizeAdminUserStatus(status) {
|
||||
return String(status || '').trim().toLowerCase() === 'disabled' ? 'disabled' : 'active'
|
||||
}
|
||||
|
||||
function safeCompare(input, expected) {
|
||||
const left = Buffer.from(String(input || ''), 'utf8')
|
||||
const right = Buffer.from(String(expected || ''), 'utf8')
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
function normalizePage(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1
|
||||
}
|
||||
|
||||
function normalizePageSize(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 20
|
||||
}
|
||||
return Math.min(100, Math.floor(parsed))
|
||||
}
|
||||
|
||||
function normalizeRoleQuery(role) {
|
||||
const normalized = String(role || '').trim().toLowerCase()
|
||||
return ['admin', 'operator'].includes(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizeStatusQuery(status) {
|
||||
const normalized = String(status || '').trim().toLowerCase()
|
||||
return ['active', 'disabled'].includes(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizeUsername(username) {
|
||||
return String(username || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePassword(password) {
|
||||
return String(password || '').trim()
|
||||
}
|
||||
|
||||
function validateUsername(username) {
|
||||
if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) {
|
||||
throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_username_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword(password) {
|
||||
if (password.length < 8) {
|
||||
throw createHttpError('后台密码至少 8 位', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_user_password_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredAdminUser(userId) {
|
||||
const user = getAdminUserById(Number(userId))
|
||||
|
||||
if (!user) {
|
||||
throw createHttpError('后台用户不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_user_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
function ensureAdminUserChangeAllowed(user, options = {}, session) {
|
||||
const nextRole = options.nextRole || user.role
|
||||
const nextStatus = options.nextStatus || user.status
|
||||
|
||||
if (session?.userId === user.id && (nextRole !== 'admin' || nextStatus !== 'active')) {
|
||||
throw createHttpError('不能停用或降级当前登录账号', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_user_self_change_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (user.role === 'admin' && (nextRole !== 'admin' || nextStatus !== 'active') && countActiveAdminUsers() <= 1) {
|
||||
throw createHttpError('至少保留一个启用中的管理员账号', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_user_last_admin_not_allowed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminUser(user) {
|
||||
return {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: normalizeAdminRole(user.role),
|
||||
status: normalizeAdminUserStatus(user.status),
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { createTaskClaimToken } from './claim-service.js'
|
||||
import { getClaimTokenById, updateClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../repositories/cdk-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../repositories/order-item-repo.js'
|
||||
import { getOrderById, listOrders } from '../repositories/order-repo.js'
|
||||
import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
|
||||
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../repositories/webhook-event-repo.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
import { reserveCdkForTask } from './cdk-service.js'
|
||||
import { replayAgisoTradeWebhookEvent } from './webhook-service.js'
|
||||
|
||||
export function getAdminDashboardSummary() {
|
||||
const db = getDb()
|
||||
const todayPrefix = nowIso().slice(0, 10)
|
||||
const summary = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders WHERE substr(created_at, 1, 10) = ?) AS today_orders,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('paid', 'cdk_reserved', 'link_generated')) AS paid_pending_claim,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')) AS claiming_tasks,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status = 'redeemed' AND substr(updated_at, 1, 10) = ?) AS redeemed_today,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')) AS abnormal_tasks,
|
||||
(SELECT COUNT(DISTINCT sku_code) FROM cdk_inventory WHERE status = 'available') AS sku_with_inventory
|
||||
`).get(todayPrefix, todayPrefix)
|
||||
|
||||
return {
|
||||
todayOrders: Number(summary?.today_orders || 0),
|
||||
paidPendingClaim: Number(summary?.paid_pending_claim || 0),
|
||||
claimingTasks: Number(summary?.claiming_tasks || 0),
|
||||
redeemedToday: Number(summary?.redeemed_today || 0),
|
||||
abnormalTasks: Number(summary?.abnormal_tasks || 0),
|
||||
skuWithInventory: Number(summary?.sku_with_inventory || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminOrders(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listOrders({
|
||||
page,
|
||||
pageSize,
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
payStatus: String(query.payStatus || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminOrderListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminOrderDetail(orderId) {
|
||||
const order = getOrderById(Number(orderId))
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const items = listOrderItemsByOrderId(order.id)
|
||||
const tasks = listTasksByOrderId(order.id)
|
||||
const webhookEvents = listWebhookEventsByOrderId(order.id)
|
||||
|
||||
return {
|
||||
order: {
|
||||
orderId: order.id,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderStatus: order.order_status,
|
||||
payStatus: order.pay_status,
|
||||
buyerId: order.buyer_id,
|
||||
buyerName: order.buyer_name,
|
||||
receiverContact: order.receiver_contact,
|
||||
totalAmount: order.total_amount,
|
||||
currency: order.currency,
|
||||
paidAt: order.paid_at,
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
rawPayload: safeParseJson(order.raw_payload_json),
|
||||
bindingSummary: buildOrderBindingSummary(tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
quantity: item.quantity,
|
||||
deliveryMode: item.delivery_mode,
|
||||
spec: safeParseJson(item.spec_json),
|
||||
})),
|
||||
tasks: tasks.map(mapAdminTaskSummary),
|
||||
webhookEvents: webhookEvents.map((event) => ({
|
||||
eventId: event.id,
|
||||
platform: event.platform,
|
||||
eventType: event.event_type,
|
||||
eventKey: event.event_key,
|
||||
signatureValid: Boolean(event.signature_valid),
|
||||
processed: Boolean(event.processed),
|
||||
processError: event.process_error,
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTasks(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listTasks({
|
||||
page,
|
||||
pageSize,
|
||||
status: String(query.status || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
taskNo: String(query.taskNo || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
roleId: String(query.roleId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminTaskListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTaskDetail(taskId) {
|
||||
const task = getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const order = getOrderById(task.order_id)
|
||||
const orderItem = order ? listOrderItemsByOrderId(order.id).find((item) => item.id === task.order_item_id) || null : null
|
||||
const claimToken = task.claim_token_id ? getClaimTokenById(task.claim_token_id) : null
|
||||
const cdk = task.reserved_cdk_id ? getCdkById(task.reserved_cdk_id) : null
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
...task,
|
||||
sku_code: orderItem?.sku_code || '',
|
||||
sku_name: orderItem?.sku_name || '',
|
||||
cdk_code: cdk?.cdk_code || '',
|
||||
claim_token: claimToken?.token || '',
|
||||
}),
|
||||
order: order
|
||||
? {
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
}
|
||||
: null,
|
||||
orderItem: orderItem
|
||||
? {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
}
|
||||
: null,
|
||||
claimToken: claimToken
|
||||
? {
|
||||
claimTokenId: claimToken.id,
|
||||
token: claimToken.token,
|
||||
status: claimToken.status,
|
||||
expiredAt: claimToken.expired_at,
|
||||
}
|
||||
: null,
|
||||
cdk: cdk
|
||||
? {
|
||||
cdkId: cdk.id,
|
||||
skuCode: cdk.sku_code,
|
||||
batchNo: cdk.batch_no,
|
||||
cdkCode: cdk.cdk_code,
|
||||
status: cdk.status,
|
||||
}
|
||||
: null,
|
||||
artifacts: safeParseJson(task.artifacts_json),
|
||||
screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '',
|
||||
operations: {
|
||||
canRetry: ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canReleaseCdk: Boolean(task.reserved_cdk_id && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)),
|
||||
canRegenerateClaimLink: ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status),
|
||||
canClose: !['redeemed', 'closed'].includes(task.task_status),
|
||||
canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTaskScreenshotPath(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (!task.screenshot_path) {
|
||||
throw createHttpError('当前任务还没有截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
export function getAdminCdks(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listCdks({
|
||||
page,
|
||||
pageSize,
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
batchNo: String(query.batchNo || '').trim(),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminCdkListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdminCdk(payload = {}) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const cdkCode = String(payload.cdkCode || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
|
||||
if (!skuCode || !cdkCode) {
|
||||
throw createHttpError('缺少 skuCode 或 cdkCode', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_create_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const created = createCdks([
|
||||
{
|
||||
skuCode,
|
||||
cdkCode,
|
||||
batchNo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
|
||||
if (created === 0) {
|
||||
throw createHttpError('CDK 已存在,不能重复新增', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_duplicate',
|
||||
})
|
||||
}
|
||||
|
||||
const { items } = listCdks({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
skuCode,
|
||||
})
|
||||
const createdItem = items.find((item) => item.cdk_code === cdkCode) || null
|
||||
|
||||
return {
|
||||
cdk: createdItem ? mapAdminCdkListItem(createdItem) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function importAdminCdks(payload = {}) {
|
||||
const rows = normalizeCdkImportRows(payload)
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw createHttpError('没有可导入的 CDK 数据', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_import_empty',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const normalizedRows = rows.map((row) => ({
|
||||
skuCode: row.skuCode,
|
||||
batchNo: row.batchNo,
|
||||
cdkCode: row.cdkCode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
const created = createCdks(normalizedRows)
|
||||
|
||||
return {
|
||||
total: normalizedRows.length,
|
||||
created,
|
||||
duplicated: normalizedRows.length - created,
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseAdminInventoryCdk(cdkId) {
|
||||
const cdk = getRequiredCdk(cdkId)
|
||||
|
||||
if (cdk.status !== 'reserved') {
|
||||
throw createHttpError('当前 CDK 不是预占状态,不能释放', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = releaseReservedCdk(cdk.id, nowIso())
|
||||
|
||||
return {
|
||||
cdk: mapAdminCdkListItem(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateAdminCdk(cdkId, payload = {}) {
|
||||
const cdk = getRequiredCdk(cdkId)
|
||||
|
||||
if (cdk.status !== 'available') {
|
||||
throw createHttpError('只有可用 CDK 才能作废,请先释放预占', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_invalidate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const reason = String(payload.reason || '').trim() || '后台手动作废'
|
||||
const updated = invalidateCdk(cdk.id, reason, nowIso())
|
||||
|
||||
return {
|
||||
cdk: mapAdminCdkListItem(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminWebhookEvents(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listWebhookEvents({
|
||||
page,
|
||||
pageSize,
|
||||
platform: String(query.platform || '').trim(),
|
||||
processed: String(query.processed || '').trim(),
|
||||
relatedOrderId: String(query.relatedOrderId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
eventId: item.id,
|
||||
platform: item.platform,
|
||||
eventType: item.event_type,
|
||||
eventKey: item.event_key,
|
||||
signatureValid: Boolean(item.signature_valid),
|
||||
processed: Boolean(item.processed),
|
||||
processError: item.process_error,
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminWebhookEventDetail(eventId) {
|
||||
const event = getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
platform: event.platform,
|
||||
eventType: event.event_type,
|
||||
eventKey: event.event_key,
|
||||
signatureValid: Boolean(event.signature_valid),
|
||||
processed: Boolean(event.processed),
|
||||
processError: event.process_error,
|
||||
relatedOrderId: event.related_order_id,
|
||||
createdAt: event.created_at,
|
||||
headers: safeParseJson(event.headers_json),
|
||||
query: safeParseJson(event.query_json),
|
||||
body: safeParseJson(event.body_json),
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayAdminWebhookEvent(eventId) {
|
||||
const event = getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (event.platform !== '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,
|
||||
}
|
||||
}
|
||||
|
||||
export function retryAdminTask(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
|
||||
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const orderItem = listOrderItemsByOrderId(task.order_id).find((item) => item.id === task.order_item_id) || null
|
||||
let reservedCdkId = task.reserved_cdk_id
|
||||
let claimTokenId = task.claim_token_id
|
||||
let nextStatus = 'link_generated'
|
||||
let lastError = ''
|
||||
let expiresAt = task.expires_at
|
||||
let claimUrl = ''
|
||||
let token = ''
|
||||
|
||||
if (!reservedCdkId) {
|
||||
const reserved = reserveCdkForTask(orderItem?.sku_code || '', task.id)
|
||||
|
||||
if (!reserved) {
|
||||
nextStatus = 'waiting_inventory'
|
||||
lastError = '库存不足,等待可用 CDK'
|
||||
} else {
|
||||
reservedCdkId = reserved.id
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === 'link_generated' && !claimTokenId) {
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
claimTokenId = claimToken.id
|
||||
expiresAt = claimToken.expired_at
|
||||
claimUrl = claimToken.claimUrl
|
||||
token = claimToken.token
|
||||
}
|
||||
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: nextStatus,
|
||||
reserved_cdk_id: reservedCdkId,
|
||||
claim_token_id: claimTokenId,
|
||||
expires_at: expiresAt,
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const payload = {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
|
||||
if (claimUrl) {
|
||||
payload.claimUrl = claimUrl
|
||||
payload.token = token
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function releaseAdminTaskCdk(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (!task.reserved_cdk_id) {
|
||||
throw createHttpError('当前任务没有预占 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_no_reserved_cdk',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
releaseReservedCdk(task.reserved_cdk_id, nowIso())
|
||||
const updatedTask = updateTask(task.id, {
|
||||
reserved_cdk_id: null,
|
||||
task_status: 'waiting_inventory',
|
||||
last_error: '已手动释放预占 CDK',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export function regenerateAdminTaskClaimLink(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重新生成领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_regenerate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.claim_token_id) {
|
||||
updateClaimToken(task.claim_token_id, {
|
||||
status: 'revoked',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
const updatedTask = updateTask(task.id, {
|
||||
claim_token_id: claimToken.id,
|
||||
expires_at: claimToken.expired_at,
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimToken.claimUrl,
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAdminTask(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_close_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: 'closed',
|
||||
last_error: task.last_error || '已手动关闭任务',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export function markAdminTaskManualReview(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: task.last_error || '已转人工处理',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminTaskSummary(task) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
lastError: task.last_error,
|
||||
retryCount: task.retry_count,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminCdkListItem(item) {
|
||||
const task = item.reserved_by_task_id ? getTaskById(item.reserved_by_task_id) : null
|
||||
const order = task?.order_id ? getOrderById(task.order_id) : null
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
cdkId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
batchNo: item.batch_no,
|
||||
cdkCode: item.cdk_code,
|
||||
status: item.status,
|
||||
reservedByTaskId: item.reserved_by_task_id,
|
||||
reservedByTaskNo: task?.task_no || '',
|
||||
platformOrderId: order?.platform_order_id || '',
|
||||
systemBindingStatus: item.status === 'delivered' ? 'system_bound' : binding.systemBindingStatus,
|
||||
userBindingStatus: item.status === 'delivered' ? 'binding_completed' : binding.userBindingStatus,
|
||||
invalidReason: item.invalid_reason || '',
|
||||
deliveredAt: item.delivered_at,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapTaskActionPayload(task) {
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
reservedCdkId: task.reserved_cdk_id,
|
||||
claimTokenId: task.claim_token_id,
|
||||
lastError: task.last_error,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredTask(taskId) {
|
||||
const task = getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
function getRequiredCdk(cdkId) {
|
||||
const cdk = getCdkById(Number(cdkId))
|
||||
|
||||
if (!cdk) {
|
||||
throw createHttpError('CDK 不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_cdk_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return cdk
|
||||
}
|
||||
|
||||
function mapAdminTaskListItem(task) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
platformOrderId: task.platform_order_id,
|
||||
skuCode: task.sku_code || '',
|
||||
skuName: task.sku_name || '',
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
roleName: task.role_name,
|
||||
roleId: task.role_id,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: task.retry_count,
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
reservedCdkCodeMasked: maskCode(task.cdk_code),
|
||||
claimToken: task.claim_token || '',
|
||||
screenshotPath: task.screenshot_path || '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminOrderListItem(item) {
|
||||
const tasks = listTasksByOrderId(item.id)
|
||||
const bindingSummary = buildOrderBindingSummary(tasks)
|
||||
|
||||
return {
|
||||
orderId: item.id,
|
||||
platform: item.platform,
|
||||
platformOrderId: item.platform_order_id,
|
||||
orderStatus: item.order_status,
|
||||
payStatus: item.pay_status,
|
||||
buyerName: item.buyer_name,
|
||||
totalAmount: item.total_amount,
|
||||
currency: item.currency,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
taskCount: Number(item.task_count || tasks.length || 0),
|
||||
systemBindingStatus: bindingSummary.systemBindingStatus,
|
||||
userBindingStatus: bindingSummary.userBindingStatus,
|
||||
systemBoundTaskCount: bindingSummary.systemBoundTaskCount,
|
||||
completedBindingTaskCount: bindingSummary.completedBindingTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrderBindingSummary(tasks) {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
||||
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
||||
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
||||
|
||||
let systemBindingStatus = 'pending_binding'
|
||||
let userBindingStatus = 'not_started'
|
||||
|
||||
if (totalTaskCount === 0) {
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
userBindingStatus = 'binding_completed'
|
||||
} else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'user_binding'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'waiting_user_claim'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'binding_exception'
|
||||
} else if (systemBoundTaskCount > 0) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
||||
systemBindingStatus = 'binding_exception'
|
||||
}
|
||||
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskBindingState(task) {
|
||||
const normalizedStatus = String(task?.task_status || '').trim()
|
||||
|
||||
if (!normalizedStatus) {
|
||||
return {
|
||||
systemBindingStatus: 'pending_binding',
|
||||
userBindingStatus: 'not_started',
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'pending_payment') {
|
||||
return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'paid') {
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'waiting_inventory') {
|
||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'manual_review') {
|
||||
return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'retry_pending') {
|
||||
return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'closed') {
|
||||
return { systemBindingStatus: 'closed', userBindingStatus: 'closed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'expired') {
|
||||
return { systemBindingStatus: 'expired', userBindingStatus: 'expired' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'link_generated') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'claimed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'role_confirmed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeeming') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeemed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
}
|
||||
|
||||
if (isTaskSystemBound(task)) {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
function isTaskSystemBound(task) {
|
||||
return Boolean(task && (task.reserved_cdk_id || task.claim_token_id))
|
||||
}
|
||||
|
||||
function normalizeCdkImportRows(payload) {
|
||||
const rows = []
|
||||
const directRows = Array.isArray(payload.rows) ? payload.rows : []
|
||||
const bulkCodes = Array.isArray(payload.codes) ? payload.codes : []
|
||||
|
||||
if (directRows.length > 0) {
|
||||
for (const row of directRows) {
|
||||
const skuCode = String(row?.skuCode || '').trim()
|
||||
const cdkCode = String(row?.cdkCode || '').trim()
|
||||
const batchNo = String(row?.batchNo || '').trim()
|
||||
|
||||
if (!skuCode || !cdkCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({ skuCode, cdkCode, batchNo })
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkCodes.length > 0) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError('批量导入时缺少 skuCode', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_import_missing_sku',
|
||||
})
|
||||
}
|
||||
|
||||
for (const code of bulkCodes) {
|
||||
const cdkCode = String(code || '').trim()
|
||||
if (!cdkCode) {
|
||||
continue
|
||||
}
|
||||
rows.push({ skuCode, cdkCode, batchNo })
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeRows(rows)
|
||||
}
|
||||
|
||||
function dedupeRows(rows) {
|
||||
const seen = new Set()
|
||||
const output = []
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.skuCode}::${row.cdkCode}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizePage(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1
|
||||
}
|
||||
|
||||
function normalizePageSize(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 20
|
||||
}
|
||||
return Math.min(100, Math.floor(parsed))
|
||||
}
|
||||
|
||||
function normalizeDateQuery(rawValue, endOfDay = false) {
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function safeParseJson(rawText) {
|
||||
try {
|
||||
return JSON.parse(String(rawText || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function maskCode(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (text.length <= 8) {
|
||||
return `${text.slice(0, 2)}****${text.slice(-2)}`
|
||||
}
|
||||
return `${text.slice(0, 4)}****${text.slice(-4)}`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { assignReservedCdk, findFirstAvailableCdkBySkuCode } from '../repositories/cdk-repo.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export function reserveCdkForTask(skuCode, taskId) {
|
||||
if (!skuCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const available = findFirstAvailableCdkBySkuCode(skuCode)
|
||||
|
||||
if (!available) {
|
||||
return null
|
||||
}
|
||||
|
||||
return assignReservedCdk(available.id, taskId, nowIso())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import { createClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { addHours, nowIso } from '../utils/time.js'
|
||||
import { randomToken } from '../utils/random.js'
|
||||
|
||||
export function createTaskClaimToken(taskId) {
|
||||
const createdAt = nowIso()
|
||||
const expiredAt = addHours(createdAt, Number(runtimeConfig.orders.tokenTtlHours || 24))
|
||||
const token = createClaimToken({
|
||||
taskId,
|
||||
token: randomToken(24),
|
||||
status: 'active',
|
||||
expiredAt,
|
||||
usedAt: null,
|
||||
maxUseCount: 1,
|
||||
usedCount: 0,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
return {
|
||||
...token,
|
||||
claimUrl: buildClaimUrl(token.token),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaimUrl(token) {
|
||||
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
|
||||
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
createTencentBrowserSession,
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
redeemTencentBrowserSession,
|
||||
} from './session.js'
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { getOrderById } from '../repositories/order-repo.js'
|
||||
import { getOrderItemById } from '../repositories/order-item-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js'
|
||||
import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
const session = await loadTaskSession(context.task, { includeQrImage })
|
||||
const syncedTask = session ? syncTaskWithSession(context.task, session) : context.task
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createClaimSession(token, payload = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
if (context.task.browser_session_id) {
|
||||
const existingSession = await getTencentBrowserSession(context.task.browser_session_id)
|
||||
const syncedTask = syncTaskWithSession(context.task, existingSession)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: existingSession,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await createTencentBrowserSession({
|
||||
loginType: payload.loginType,
|
||||
})
|
||||
const updatedTask = updateTask(context.task.id, {
|
||||
task_status: 'claimed',
|
||||
browser_session_id: session.sessionId,
|
||||
login_type: session.loginType,
|
||||
claimed_at: context.task.claimed_at || nowIso(),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClaimSessionSummary(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
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 syncedTask = syncTaskWithSession(context.task, session)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmClaimRole(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
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 = 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 || ''),
|
||||
role_confirmed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function redeemClaimTask(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
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 cdk = context.task.reserved_cdk_id ? getCdkById(context.task.reserved_cdk_id) : null
|
||||
|
||||
if (!cdk || !String(cdk.cdk_code || '').trim()) {
|
||||
throw createHttpError('当前任务没有可用的预占 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_cdk_not_reserved',
|
||||
})
|
||||
}
|
||||
|
||||
updateTask(context.task.id, {
|
||||
task_status: 'redeeming',
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
|
||||
code: cdk.cdk_code,
|
||||
})
|
||||
const finalRedeem = session.redeem?.final?.redeem || null
|
||||
const updatedTask = updateTask(context.task.id, {
|
||||
task_status: 'redeemed',
|
||||
result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'),
|
||||
result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'),
|
||||
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
||||
artifacts_json: JSON.stringify(session.artifacts || {}),
|
||||
redeemed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
markCdkDelivered(cdk.id, nowIso())
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
} catch (error) {
|
||||
const nextRetryCount = Number(context.task.retry_count || 0) + 1
|
||||
const failedTask = updateTask(context.task.id, {
|
||||
task_status: 'retry_pending',
|
||||
retry_count: nextRetryCount,
|
||||
last_error: error instanceof Error ? error.message : String(error || ''),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
|
||||
task: failedTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 getClaimContext(token) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createHttpError('缺少领取 token', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_claim_token',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = findClaimTokenByToken(normalized)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接无效或不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const task = 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 = expireClaimContext(claimToken, task)
|
||||
|
||||
throw createHttpError('领取链接已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_expired',
|
||||
context: expiredContext,
|
||||
})
|
||||
}
|
||||
|
||||
const order = getOrderById(task.order_id)
|
||||
const orderItem = getOrderItemById(task.order_item_id)
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
function assertTaskCanProceed(task) {
|
||||
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
|
||||
throw createHttpError('当前任务已经结束,不能继续操作', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_task_closed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = 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.reserved_cdk_id) {
|
||||
releaseReservedCdk(task.reserved_cdk_id, now)
|
||||
}
|
||||
|
||||
nextTask = updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
reserved_cdk_id: null,
|
||||
last_error: '领取链接已过期,预占 CDK 已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken: nextClaimToken,
|
||||
task: nextTask,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskSession(task, { includeQrImage = false } = {}) {
|
||||
if (!task.browser_session_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return includeQrImage
|
||||
? getTencentBrowserSession(task.browser_session_id)
|
||||
: getTencentBrowserSessionSummary(task.browser_session_id)
|
||||
}
|
||||
|
||||
function syncTaskWithSession(task, session) {
|
||||
const activityInfo = session.activityInfo || null
|
||||
const patch = {
|
||||
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.claimed_at = task.claimed_at || nowIso()
|
||||
}
|
||||
|
||||
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
|
||||
patch.screenshot_path = task.screenshot_path || ''
|
||||
}
|
||||
|
||||
return updateTask(task.id, patch)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
expiresAt: task.expires_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
loginType: task.login_type,
|
||||
lastError: task.last_error,
|
||||
browserSessionId: task.browser_session_id,
|
||||
},
|
||||
order: {
|
||||
orderId: order.id,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
totalAmount: order.total_amount,
|
||||
currency: order.currency,
|
||||
},
|
||||
orderItem: {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
session,
|
||||
result: task.redeemed_at || session?.status === 'redeemed'
|
||||
? {
|
||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
||||
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
|
||||
screenshotReady,
|
||||
screenshotUrl,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createTask, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
|
||||
import { reserveCdkForTask } from './cdk-service.js'
|
||||
import { createTaskClaimToken } from './claim-service.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
import { randomId } from '../utils/random.js'
|
||||
|
||||
export function syncDeliveryTasksForOrder(order, orderItems) {
|
||||
const existingTasks = listTasksByOrderId(order.id)
|
||||
|
||||
if (existingTasks.length > 0) {
|
||||
if (order.pay_status !== 'paid') {
|
||||
return existingTasks
|
||||
}
|
||||
|
||||
const itemMap = new Map(orderItems.map((item) => [item.id, item]))
|
||||
return existingTasks.map((task) => preparePaidTask({
|
||||
...task,
|
||||
skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
|
||||
skuName: itemMap.get(task.order_item_id)?.sku_name || '',
|
||||
}))
|
||||
}
|
||||
|
||||
const tasks = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
|
||||
for (let index = 0; index < quantity; index += 1) {
|
||||
const createdAt = nowIso()
|
||||
const initialStatus = order.pay_status === 'paid' ? 'paid' : 'pending_payment'
|
||||
|
||||
const task = createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
taskNo: randomId('DT'),
|
||||
taskStatus: initialStatus,
|
||||
loginType: '',
|
||||
claimTokenId: null,
|
||||
reservedCdkId: null,
|
||||
browserSessionId: '',
|
||||
nickname: '',
|
||||
roleId: '',
|
||||
roleName: '',
|
||||
area: '',
|
||||
partitionName: '',
|
||||
resultCode: '',
|
||||
resultMessage: '',
|
||||
screenshotPath: '',
|
||||
artifactsJson: '{}',
|
||||
lastError: '',
|
||||
retryCount: 0,
|
||||
expiresAt: null,
|
||||
claimedAt: null,
|
||||
roleConfirmedAt: null,
|
||||
redeemedAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
tasks.push({
|
||||
...task,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (order.pay_status !== 'paid') {
|
||||
return tasks
|
||||
}
|
||||
|
||||
return tasks.map((task) => preparePaidTask(task))
|
||||
}
|
||||
|
||||
function preparePaidTask(task) {
|
||||
const now = nowIso()
|
||||
|
||||
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
|
||||
return task
|
||||
}
|
||||
|
||||
if (!task.skuCode) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: '未匹配到 SKU,无法为任务分配 CDK',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.reserved_cdk_id && task.claim_token_id) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const reserved = reserveCdkForTask(task.skuCode, task.id)
|
||||
|
||||
if (!reserved) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
last_error: '库存不足,等待可用 CDK',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
|
||||
return updateTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
reserved_cdk_id: reserved.id,
|
||||
claim_token_id: claimToken.id,
|
||||
last_error: '',
|
||||
expires_at: claimToken.expired_at,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import crypto from 'node:crypto'
|
||||
import {
|
||||
createMessageDelivery,
|
||||
findLatestSuccessfulMessageDeliveryByTask,
|
||||
updateMessageDelivery,
|
||||
} from '../repositories/message-delivery-repo.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
const AGISO_MESSAGE_CHANNEL = 'agiso_im'
|
||||
|
||||
export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
|
||||
if (!order || !task || !claimUrl) {
|
||||
return { sent: false, skipped: true, reason: 'missing_message_context' }
|
||||
}
|
||||
|
||||
const config = runtimeConfig.platforms?.agiso?.messaging || {}
|
||||
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 = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_MESSAGE_CHANNEL)
|
||||
if (successful) {
|
||||
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
|
||||
}
|
||||
|
||||
const messageContent = renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template: config.messageTemplate })
|
||||
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 = nowIso()
|
||||
const delivery = createMessageDelivery({
|
||||
platform: 'agiso',
|
||||
channel: AGISO_MESSAGE_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,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(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 updated = updateMessageDelivery(delivery.id, {
|
||||
status: success ? 'success' : 'failed',
|
||||
response_status: response.status,
|
||||
response_json: JSON.stringify(parsed ?? { rawText }),
|
||||
error_message: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
|
||||
sent_at: success ? nowIso() : null,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: success,
|
||||
skipped: false,
|
||||
deliveryId: updated?.id || delivery.id,
|
||||
responseStatus: response.status,
|
||||
response: parsed ?? { rawText },
|
||||
errorMessage: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
|
||||
updateMessageDelivery(delivery.id, {
|
||||
status: 'failed',
|
||||
response_status: 0,
|
||||
response_json: '{}',
|
||||
error_message: message,
|
||||
sent_at: null,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: false,
|
||||
skipped: false,
|
||||
deliveryId: delivery.id,
|
||||
errorMessage: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestUrl(endpoint) {
|
||||
return new URL(endpoint).toString()
|
||||
}
|
||||
|
||||
function buildRequestHeaders({ accessToken, apiVersion }) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: apiVersion,
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({ tid, msg, appSecret }) {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const payload = {
|
||||
tid,
|
||||
msg,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params, appSecret) {
|
||||
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 }) {
|
||||
const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
|
||||
|
||||
return source
|
||||
.replaceAll('{platformOrderId}', String(order.platform_order_id || ''))
|
||||
.replaceAll('{taskNo}', String(task.task_no || ''))
|
||||
.replaceAll('{claimUrl}', claimUrl)
|
||||
.replaceAll('{expiredAt}', String(expiredAt || '尽快'))
|
||||
}
|
||||
|
||||
function isAgisoSendSuccess(statusCode, payload) {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (payload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveAgisoErrorMessage(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}`
|
||||
}
|
||||
|
||||
function safeParseJson(rawText) {
|
||||
const normalized = String(rawText || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function maskHeadersForStorage(headers) {
|
||||
const output = { ...headers }
|
||||
|
||||
if (output.Authorization) {
|
||||
output.Authorization = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function maskBodyForStorage(body) {
|
||||
const output = { ...body }
|
||||
|
||||
if (output.sign) {
|
||||
output.sign = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import readline from 'node:readline'
|
||||
import process from 'node:process'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
let workerPromise = null
|
||||
let requestSequence = 0
|
||||
const pendingRequests = new Map()
|
||||
let stderrBuffer = []
|
||||
|
||||
export async function recognizeTencentCaptcha(payload) {
|
||||
return callOcrWorker('recognize', payload)
|
||||
}
|
||||
|
||||
export async function batchRecognizeTencentCaptcha(payload) {
|
||||
return callOcrWorker('batch', payload, { timeoutMs: 180_000 })
|
||||
}
|
||||
|
||||
export async function warmupLocalOcrWorker() {
|
||||
await ensureOcrWorker()
|
||||
}
|
||||
|
||||
export async function closeLocalOcrWorker() {
|
||||
const worker = await workerPromise?.catch(() => null)
|
||||
|
||||
if (!worker) {
|
||||
workerPromise = null
|
||||
return
|
||||
}
|
||||
|
||||
worker.child.kill()
|
||||
workerPromise = null
|
||||
}
|
||||
|
||||
async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const worker = await ensureOcrWorker()
|
||||
const requestId = `ocr-${Date.now()}-${++requestSequence}`
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
worker.child.kill()
|
||||
reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常'))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
|
||||
worker.child.stdin.write(
|
||||
`${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`,
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureOcrWorker() {
|
||||
if (!workerPromise) {
|
||||
workerPromise = createOcrWorker().catch((error) => {
|
||||
workerPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return workerPromise
|
||||
}
|
||||
|
||||
function createOcrWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const projectRoot = resolveOcrProjectRoot()
|
||||
stderrBuffer = []
|
||||
|
||||
const child = spawn('uv', ['run', 'ocr-worker', 'worker'], {
|
||||
cwd: projectRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
})
|
||||
child.stdin.setDefaultEncoding('utf8')
|
||||
|
||||
const stdoutReader = readline.createInterface({ input: child.stdout })
|
||||
const worker = { child, stdoutReader }
|
||||
|
||||
const readyTimer = setTimeout(() => {
|
||||
reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`))
|
||||
child.kill()
|
||||
}, 15_000)
|
||||
|
||||
let ready = false
|
||||
|
||||
stdoutReader.on('line', (line) => {
|
||||
const message = tryParseJson(line)
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
if (!ready) {
|
||||
ready = true
|
||||
clearTimeout(readyTimer)
|
||||
resolve(worker)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type !== 'response') {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = String(message.id || '')
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
pending.resolve(message.payload)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
stderrBuffer.push(text)
|
||||
if (stderrBuffer.length > 20) {
|
||||
stderrBuffer = stderrBuffer.slice(-20)
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(readyTimer)
|
||||
const workerError = buildWorkerError(error)
|
||||
failAllPendingRequests(workerError)
|
||||
if (!ready) {
|
||||
reject(workerError)
|
||||
}
|
||||
workerPromise = null
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(readyTimer)
|
||||
const workerError = new Error(
|
||||
buildWorkerExitMessage({
|
||||
code,
|
||||
signal,
|
||||
projectRoot,
|
||||
stderrText: stderrBuffer.join('\n'),
|
||||
}),
|
||||
)
|
||||
failAllPendingRequests(workerError)
|
||||
if (!ready) {
|
||||
reject(workerError)
|
||||
}
|
||||
workerPromise = null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function failAllPendingRequests(error) {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
pendingRequests.delete(requestId)
|
||||
pending.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOcrProjectRoot() {
|
||||
return String(runtimeConfig.ocr.projectRoot || '').trim()
|
||||
}
|
||||
|
||||
function buildWorkerError(error) {
|
||||
if (error instanceof Error && error.message.includes('spawn uv ENOENT')) {
|
||||
return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里')
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) {
|
||||
const reason = signal
|
||||
? `signal ${signal}`
|
||||
: typeof code === 'number'
|
||||
? `exit code ${code}`
|
||||
: 'unknown reason'
|
||||
|
||||
const installHint = `请先执行: cd ${projectRoot} && uv sync`
|
||||
const stderrHint = stderrText ? `\n${stderrText}` : ''
|
||||
|
||||
return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}`
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../repositories/order-repo.js'
|
||||
import { replaceOrderItems } from '../repositories/order-item-repo.js'
|
||||
import { getClaimTokenById } from '../repositories/claim-token-repo.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { ensureAgisoClaimMessageDeliveredForTask } from './message-service.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export async function upsertOrderFromWebhook(event) {
|
||||
const now = nowIso()
|
||||
const existing = findOrderByPlatformOrderId(event.platform, event.platformOrderId)
|
||||
const basePayload = {
|
||||
platform: event.platform,
|
||||
platformOrderId: event.platformOrderId,
|
||||
orderStatus: event.orderStatus,
|
||||
payStatus: event.payStatus,
|
||||
buyerId: event.buyerId,
|
||||
buyerName: event.buyerName,
|
||||
receiverContact: event.receiverContact,
|
||||
totalAmount: event.totalAmount,
|
||||
currency: event.currency,
|
||||
rawPayloadJson: JSON.stringify(event.rawPayload),
|
||||
paidAt: event.paidAt,
|
||||
}
|
||||
|
||||
const order = existing
|
||||
? updateOrder(existing.id, {
|
||||
...basePayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
: createOrder({
|
||||
...basePayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const orderItems = replaceOrderItems(
|
||||
order.id,
|
||||
event.items.map((item) => ({
|
||||
skuCode: item.skuCode,
|
||||
skuName: item.skuName,
|
||||
quantity: item.quantity,
|
||||
specJson: JSON.stringify(item.spec || {}),
|
||||
deliveryMode: 'claim_link',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
|
||||
const tasks = syncDeliveryTasksForOrder(order, orderItems)
|
||||
const messageDeliveries = []
|
||||
|
||||
for (const task of tasks) {
|
||||
if (event.platform !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const claimToken = getClaimTokenById(task.claim_token_id)
|
||||
if (!claimToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await ensureAgisoClaimMessageDeliveredForTask({
|
||||
order,
|
||||
task,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
expiredAt: claimToken.expired_at,
|
||||
})
|
||||
messageDeliveries.push({
|
||||
taskId: task.id,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
orderItems,
|
||||
tasks,
|
||||
messageDeliveries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
|
||||
export function logBrowserSessionDebug(scope, detail) {
|
||||
if (!SESSION_DEBUG_ENABLED) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof detail === 'undefined') {
|
||||
console.log(`[browser/session][debug] ${scope}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[browser/session][debug] ${scope}:`, detail)
|
||||
}
|
||||
|
||||
export async function waitForFrame(page, predicate, timeoutMs = 15_000) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const matched = page.frames().find((frame) => {
|
||||
try {
|
||||
return predicate(frame)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
throw new Error('未找到登录二维码 frame')
|
||||
}
|
||||
|
||||
export async function downloadRemoteQrImage(page, qrUrl, qrImagePath, referer = '', loginType = 'unknown') {
|
||||
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) {
|
||||
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')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4'
|
||||
const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
|
||||
const BEIJING_TIME_PROOF_CLIP = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: BEIJING_TIME_PROOF_VIEWPORT.width,
|
||||
height: 620,
|
||||
}
|
||||
const DEFAULT_REDEEM_PROOF_MODE = 'full'
|
||||
|
||||
export async function saveRedeemArtifacts({
|
||||
browserContext,
|
||||
page,
|
||||
sessionDir,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
}) {
|
||||
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,
|
||||
}) {
|
||||
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: '',
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
error: '',
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) {
|
||||
const normalized = String(rawMode || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (normalized === 'basic' || normalized === 'off') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return DEFAULT_REDEEM_PROOF_MODE
|
||||
}
|
||||
|
||||
function buildArtifactPaths(outputDir) {
|
||||
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'),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRedeemResultFile(
|
||||
resultPath,
|
||||
{
|
||||
proofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
htmlPath = '',
|
||||
redeemPageScreenshotPath = '',
|
||||
beijingTimeScreenshotPath = '',
|
||||
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
|
||||
beijingTimeError = '',
|
||||
},
|
||||
) {
|
||||
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',
|
||||
)
|
||||
}
|
||||
|
||||
async function showResultDialog(page, message) {
|
||||
await page.evaluate((text) => {
|
||||
document.querySelectorAll('iframe').forEach((element) => element.remove())
|
||||
document.querySelectorAll('.pop').forEach((element) => {
|
||||
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)
|
||||
}
|
||||
|
||||
async function captureBeijingTimeProof(browserContext, { screenshotPath }) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function composeProofScreenshot(
|
||||
browserContext,
|
||||
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl },
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) {
|
||||
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>`
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
|
||||
|
||||
const QQ_QR_TARGET_SIZE = 144
|
||||
|
||||
export async function ensureQqLoginReady(page) {
|
||||
try {
|
||||
const frame = await waitForFrame(
|
||||
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, qrImagePath) {
|
||||
const frame = await waitForFrame(
|
||||
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: Boolean(body?.length),
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (body?.length) {
|
||||
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, frame = null) {
|
||||
const effectiveFrame =
|
||||
frame ||
|
||||
await waitForFrame(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) {
|
||||
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 = await frame
|
||||
.locator('#qrlogin_img')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
export async function runTencentBrowserRedeem({
|
||||
session,
|
||||
code,
|
||||
maxAttempts,
|
||||
ensureLoggedInPresentation,
|
||||
ensureActivityInfoReady,
|
||||
fillRedeemCodeInBrowser,
|
||||
capturePageCaptchaForOcr,
|
||||
recognizeTencentCaptcha,
|
||||
submitRedeemInBrowser,
|
||||
isCaptchaRejectedResult,
|
||||
refreshPageCaptcha,
|
||||
persistSessionState,
|
||||
buildSessionPayload,
|
||||
saveRedeemArtifacts,
|
||||
activityUrl,
|
||||
}) {
|
||||
session.status = 'redeeming'
|
||||
session.notice = '正在识别验证码并提交兑换'
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
const attempts = []
|
||||
let finalResult = 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 attemptRecord = {
|
||||
attempt,
|
||||
verifyCode,
|
||||
verifysession: captcha.verifysession,
|
||||
ocrSample: ocr?.data?.saved || null,
|
||||
redeem,
|
||||
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 = String(finalResult.redeem?.sMsg || '兑换完成')
|
||||
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, { sessionDir, attempt, ensureActivityInfoReady }) {
|
||||
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, payload, { ensureActivityInfoReady }) {
|
||||
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, code, { 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, code, { ensureActivityInfoReady }) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
|
||||
await fillRedeemCodeInBrowser(page, code, { activityInfo })
|
||||
}
|
||||
|
||||
export async function refreshPageCaptcha(page, verifyImgId) {
|
||||
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) {
|
||||
const retCode = Number(result?.iRet)
|
||||
|
||||
if (Number.isFinite(retCode) && retCode === -100) {
|
||||
return true
|
||||
}
|
||||
|
||||
const text = [
|
||||
String(result?.sMsg || ''),
|
||||
String(result?.msg || ''),
|
||||
String(result?.popup?.text || ''),
|
||||
String(result?.popup?.detail || ''),
|
||||
]
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
return /验证码|校验码/.test(text)
|
||||
}
|
||||
|
||||
function resetRedeemPopup(page) {
|
||||
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) {
|
||||
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) {
|
||||
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, submitSelector) {
|
||||
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, selector, value) {
|
||||
const nextValue = String(value ?? '')
|
||||
|
||||
const appliedValue = await page.evaluate(
|
||||
({ targetSelector, targetValue }) => {
|
||||
const isVisible = (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) {
|
||||
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) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inferRedeemCodeFromPopup(popup) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
|
||||
|
||||
export async function ensureWxLoginReady(page) {
|
||||
try {
|
||||
const frame = await waitForFrame(
|
||||
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, qrImagePath) {
|
||||
const qrLocator = await findWxQrLocator(page)
|
||||
const frame = await waitForFrame(
|
||||
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: Boolean(downloaded?.length),
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (downloaded?.length) {
|
||||
await fs.writeFile(qrImagePath, downloaded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logBrowserSessionDebug('wx.capture.fallbackScreenshot', {
|
||||
qrImagePath,
|
||||
})
|
||||
await qrLocator.screenshot({ path: qrImagePath })
|
||||
}
|
||||
|
||||
export async function findWxQrLocator(page) {
|
||||
await ensureWxLoginReady(page)
|
||||
|
||||
const frame = await waitForFrame(
|
||||
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) {
|
||||
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 qrVisible = await frame
|
||||
.locator('.js_qrcode_img')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
|
||||
|
||||
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) {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const isVisible = (element) => {
|
||||
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) {
|
||||
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) {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const isVisible = (element) => {
|
||||
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(() => '')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js'
|
||||
import { upsertOrderFromWebhook } from './order-service.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export async function processAgisoTradeWebhook(requestLike) {
|
||||
const parsed = parseAgisoTradeRequest(requestLike)
|
||||
const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed))
|
||||
|
||||
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
|
||||
}
|
||||
|
||||
export async function replayAgisoTradeWebhookEvent(webhookEvent) {
|
||||
const requestLike = {
|
||||
headers: safeParseJson(webhookEvent.headers_json),
|
||||
query: safeParseJson(webhookEvent.query_json),
|
||||
body: safeParseJson(webhookEvent.body_json),
|
||||
}
|
||||
const parsed = parseAgisoTradeRequest(requestLike)
|
||||
|
||||
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
|
||||
}
|
||||
|
||||
function buildWebhookEventInput(requestLike, parsed) {
|
||||
return {
|
||||
platform: 'agiso',
|
||||
eventType: parsed.eventType,
|
||||
eventKey: parsed.eventKey,
|
||||
signatureValid: parsed.signatureValid,
|
||||
headersJson: JSON.stringify(requestLike.headers || {}),
|
||||
queryJson: JSON.stringify(requestLike.query || {}),
|
||||
bodyJson: JSON.stringify(requestLike.body || {}),
|
||||
processed: false,
|
||||
processError: '',
|
||||
relatedOrderId: null,
|
||||
createdAt: nowIso(),
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAgisoTradeWebhook(parsed, webhookEventId) {
|
||||
try {
|
||||
if (!parsed.signatureValid) {
|
||||
throw createHttpError('验签失败', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_signature',
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsed.platformOrderId) {
|
||||
throw createHttpError('缺少平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await upsertOrderFromWebhook(parsed)
|
||||
updateWebhookEvent(webhookEventId, {
|
||||
processed: true,
|
||||
process_error: '',
|
||||
related_order_id: result.order.id,
|
||||
})
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
eventType: parsed.eventType,
|
||||
platformOrderId: parsed.platformOrderId,
|
||||
orderId: result.order.id,
|
||||
taskCount: result.tasks.length,
|
||||
messageDeliveries: result.messageDeliveries || [],
|
||||
tasks: result.tasks.map((task) => ({
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
updateWebhookEvent(webhookEventId, {
|
||||
processed: false,
|
||||
process_error: error instanceof Error ? error.message : String(error || ''),
|
||||
related_order_id: null,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgisoTradeRequest(requestLike) {
|
||||
const query = normalizeRecord(requestLike.query)
|
||||
const body = normalizeRecord(requestLike.body)
|
||||
const rawJson = String(body.json || '').trim()
|
||||
const payload = rawJson ? parsePayloadJson(rawJson) : body
|
||||
const timestamp = String(query.timestamp || '').trim()
|
||||
const sign = String(query.sign || '').trim().toLowerCase()
|
||||
const eventType = resolveEventType(query.aopic)
|
||||
const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign })
|
||||
const platformOrderId = pickFirstNonEmpty([
|
||||
payload.biz_order_id,
|
||||
payload.Tid,
|
||||
payload.tid,
|
||||
payload.order_id,
|
||||
payload.orderId,
|
||||
])
|
||||
|
||||
return {
|
||||
platform: 'agiso',
|
||||
eventType,
|
||||
eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`,
|
||||
signatureValid,
|
||||
platformOrderId,
|
||||
orderStatus: resolveOrderStatus(eventType, payload),
|
||||
payStatus: resolvePayStatus(eventType, payload),
|
||||
buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]),
|
||||
buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]),
|
||||
receiverContact: pickFirstNonEmpty([
|
||||
payload.receiver_contact,
|
||||
payload.receiverContact,
|
||||
payload.mobile,
|
||||
payload.phone,
|
||||
]),
|
||||
totalAmount: normalizeInteger(
|
||||
pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]),
|
||||
),
|
||||
currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY',
|
||||
paidAt: resolvePaidAt(eventType, payload),
|
||||
rawPayload: payload,
|
||||
items: normalizeOrderItems(payload),
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
||||
const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim()
|
||||
|
||||
if (!appSecret) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!rawJson || !timestamp || !sign) {
|
||||
return false
|
||||
}
|
||||
|
||||
const documentedDigest = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
if (documentedDigest === sign) {
|
||||
return true
|
||||
}
|
||||
|
||||
const legacyDigest = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
return legacyDigest === sign
|
||||
}
|
||||
|
||||
function resolveEventType(aopic) {
|
||||
const normalized = String(aopic || '').trim()
|
||||
|
||||
if (normalized === '1') {
|
||||
return 'payment_success'
|
||||
}
|
||||
|
||||
if (normalized === '32') {
|
||||
return 'trade_create'
|
||||
}
|
||||
|
||||
if (normalized === '256') {
|
||||
return 'buyer_confirm_goods'
|
||||
}
|
||||
|
||||
if (normalized === '128') {
|
||||
return 'trade_closed'
|
||||
}
|
||||
|
||||
return 'trade_event'
|
||||
}
|
||||
|
||||
function resolveOrderStatus(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (eventType === 'trade_closed') {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (rawStatus === 6) {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
if (rawStatus === 5) {
|
||||
return 'refunded'
|
||||
}
|
||||
|
||||
return 'created'
|
||||
}
|
||||
|
||||
function resolvePayStatus(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (eventType === 'trade_closed') {
|
||||
return 'unpaid'
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (rawStatus === 5) {
|
||||
return 'refunded'
|
||||
}
|
||||
|
||||
return 'unpaid'
|
||||
}
|
||||
|
||||
function resolvePaidAt(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return nowIso()
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return nowIso()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePayloadJson(rawJson) {
|
||||
if (!rawJson) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson)
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
throw createHttpError('json 参数不是合法 JSON', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_json_payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderItems(payload) {
|
||||
const items = Array.isArray(payload.items) && payload.items.length > 0
|
||||
? payload.items
|
||||
: [payload]
|
||||
|
||||
return items.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const rawSkuKey = pickFirstNonEmpty([
|
||||
source.sku_code,
|
||||
source.skuCode,
|
||||
source.goods_sku,
|
||||
source.item_id,
|
||||
source.itemId,
|
||||
source.goods_id,
|
||||
source.goodsId,
|
||||
source.num_iid,
|
||||
])
|
||||
const skuCode = resolveSkuCode(rawSkuKey)
|
||||
|
||||
return {
|
||||
skuCode,
|
||||
skuName: pickFirstNonEmpty([
|
||||
source.sku_name,
|
||||
source.skuName,
|
||||
source.goods_name,
|
||||
source.goodsName,
|
||||
skuCode,
|
||||
]),
|
||||
quantity: Math.max(
|
||||
1,
|
||||
normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1,
|
||||
),
|
||||
spec: source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveSkuCode(rawKey) {
|
||||
const mappings = runtimeConfig.orders.skuMappings
|
||||
|
||||
if (rawKey && mappings && typeof mappings === 'object') {
|
||||
const direct = mappings[String(rawKey)]
|
||||
if (typeof direct === 'string' && direct.trim()) {
|
||||
return direct.trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawKey === 'string' && rawKey.trim()) {
|
||||
return rawKey.trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeRecord(value) {
|
||||
return isPlainObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function normalizeInteger(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : 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]'
|
||||
}
|
||||
|
||||
function safeParseJson(rawValue) {
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user