This commit is contained in:
yml
2026-04-08 16:30:42 +08:00
commit 313c036845
131 changed files with 22393 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
const CONFIG_ROOT = path.join(PROJECT_ROOT, 'config')
const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))
const localConfig = loadConfig(path.join(CONFIG_ROOT, 'local.cjs'))
const mergedConfig = deepMerge(defaultConfig, localConfig)
export const runtimeConfig = applyEnvOverrides(mergedConfig)
function loadConfig(configPath) {
if (!fs.existsSync(configPath)) {
return {}
}
const loaded = require(configPath)
return isPlainObject(loaded) ? loaded : {}
}
function applyEnvOverrides(baseConfig) {
const nextConfig = deepMerge(baseConfig, {})
const port = parseInteger(process.env.PORT)
if (port !== null) {
nextConfig.server.port = port
}
const chromePath = String(process.env.CHROME_PATH || '').trim()
if (chromePath) {
nextConfig.browser.chromePath = chromePath
}
const browserHeadless = parseBoolean(process.env.TENCENT_BROWSER_HEADLESS)
if (browserHeadless !== null) {
nextConfig.browser.headless = browserHeadless
}
const browserDevtools = parseBoolean(process.env.TENCENT_BROWSER_DEVTOOLS)
if (browserDevtools !== null) {
nextConfig.browser.devtools = browserDevtools
}
const browserKeepAlive = parseBoolean(process.env.TENCENT_BROWSER_KEEP_ALIVE)
if (browserKeepAlive !== null) {
nextConfig.browser.keepAlive = browserKeepAlive
}
const browserPrewarm = parseBoolean(process.env.TENCENT_BROWSER_PREWARM)
if (browserPrewarm !== null) {
nextConfig.browser.prewarm = browserPrewarm
}
const browserSlowMoMs = parseInteger(process.env.TENCENT_BROWSER_SLOW_MO)
if (browserSlowMoMs !== null) {
nextConfig.browser.slowMoMs = browserSlowMoMs
}
const sessionDebug = parseBoolean(process.env.TENCENT_SESSION_DEBUG)
if (sessionDebug !== null) {
nextConfig.session.debug = sessionDebug
}
const ocrProjectRoot = String(process.env.OCR_PROJECT_ROOT || '').trim()
if (ocrProjectRoot) {
nextConfig.ocr.projectRoot = ocrProjectRoot
}
const proofMode = String(process.env.TENCENT_REDEEM_PROOF_MODE || '').trim()
if (proofMode) {
nextConfig.redeem.proofMode = proofMode
}
const databaseFilePath = String(process.env.DATABASE_FILE_PATH || '').trim()
if (databaseFilePath) {
nextConfig.database.filePath = databaseFilePath
}
const claimBaseUrl = String(process.env.CLAIM_BASE_URL || '').trim()
if (claimBaseUrl) {
nextConfig.orders.claimBaseUrl = claimBaseUrl
}
const skuMappings = parseJsonObject(process.env.ORDER_SKU_MAPPINGS_JSON)
if (skuMappings) {
nextConfig.orders.skuMappings = skuMappings
}
const adminSessionSecret = String(process.env.ADMIN_SESSION_SECRET || '').trim()
if (adminSessionSecret) {
nextConfig.admin.sessionSecret = adminSessionSecret
}
const adminSessionTtlHours = parseInteger(process.env.ADMIN_SESSION_TTL_HOURS)
if (adminSessionTtlHours !== null) {
nextConfig.admin.sessionTtlHours = adminSessionTtlHours
}
const adminDefaultUsers = parseJsonArray(process.env.ADMIN_DEFAULT_USERS_JSON)
if (adminDefaultUsers) {
nextConfig.admin.defaultUsers = adminDefaultUsers
}
const agisoAppSecret = String(process.env.AGISO_APP_SECRET || '').trim()
if (agisoAppSecret) {
nextConfig.platforms.agiso.appSecret = agisoAppSecret
}
const agisoAppId = String(process.env.AGISO_APP_ID || '').trim()
if (agisoAppId) {
nextConfig.platforms.agiso.messaging.appId = agisoAppId
}
const agisoAccessToken = String(process.env.AGISO_ACCESS_TOKEN || '').trim()
if (agisoAccessToken) {
nextConfig.platforms.agiso.messaging.accessToken = agisoAccessToken
}
const agisoMessageAppSecret = String(process.env.AGISO_MESSAGE_APP_SECRET || '').trim()
if (agisoMessageAppSecret) {
nextConfig.platforms.agiso.messaging.appSecret = agisoMessageAppSecret
}
const agisoMessageApiVersion = String(process.env.AGISO_MESSAGE_API_VERSION || '').trim()
if (agisoMessageApiVersion) {
nextConfig.platforms.agiso.messaging.apiVersion = agisoMessageApiVersion
}
const agisoSendMessageEndpoint = String(process.env.AGISO_SEND_MESSAGE_ENDPOINT || '').trim()
if (agisoSendMessageEndpoint) {
nextConfig.platforms.agiso.messaging.sendMessageEndpoint = agisoSendMessageEndpoint
}
const agisoMessagingEnabled = parseBoolean(process.env.AGISO_MESSAGING_ENABLED)
if (agisoMessagingEnabled !== null) {
nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled
}
const agisoMessageTemplate = String(process.env.AGISO_MESSAGE_TEMPLATE || '').trim()
if (agisoMessageTemplate) {
nextConfig.platforms.agiso.messaging.messageTemplate = agisoMessageTemplate
}
return nextConfig
}
function deepMerge(baseValue, overrideValue) {
if (!isPlainObject(baseValue)) {
return cloneValue(overrideValue)
}
const result = cloneValue(baseValue)
if (!isPlainObject(overrideValue)) {
return result
}
for (const [key, value] of Object.entries(overrideValue)) {
if (isPlainObject(value) && isPlainObject(result[key])) {
result[key] = deepMerge(result[key], value)
continue
}
result[key] = cloneValue(value)
}
return result
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map((item) => cloneValue(item))
}
if (isPlainObject(value)) {
const output = {}
for (const [key, item] of Object.entries(value)) {
output[key] = cloneValue(item)
}
return output
}
return value
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
function parseBoolean(rawValue) {
const normalized = String(rawValue || '')
.trim()
.toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function parseInteger(rawValue) {
const normalized = String(rawValue || '').trim()
if (!normalized) {
return null
}
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
function parseJsonObject(rawValue) {
const normalized = String(rawValue || '').trim()
if (!normalized) {
return null
}
try {
const parsed = JSON.parse(normalized)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
}
function parseJsonArray(rawValue) {
const normalized = String(rawValue || '').trim()
if (!normalized) {
return null
}
try {
const parsed = JSON.parse(normalized)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
+43
View File
@@ -0,0 +1,43 @@
import fs from 'node:fs'
import path from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { runtimeConfig } from '../config/runtime.js'
let databaseInstance = null
export function getDb() {
if (!databaseInstance) {
const filePath = resolveDatabasePath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
databaseInstance = new DatabaseSync(filePath)
databaseInstance.exec('PRAGMA foreign_keys = ON;')
databaseInstance.exec('PRAGMA journal_mode = WAL;')
}
return databaseInstance
}
export function runInTransaction(fn) {
const db = getDb()
db.exec('BEGIN')
try {
const result = fn(db)
db.exec('COMMIT')
return result
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
function resolveDatabasePath() {
const configured = String(runtimeConfig.database.filePath || '').trim()
if (!configured) {
throw new Error('缺少数据库文件路径配置')
}
return path.resolve(configured)
}
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import process from 'node:process'
import { getDb } from './client.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations')
export function runDatabaseMigrations() {
const db = getDb()
const files = fs.readdirSync(MIGRATIONS_DIR)
.filter((name) => name.endsWith('.sql'))
.sort()
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
);
`)
const appliedRows = db.prepare('SELECT filename FROM schema_migrations').all()
const applied = new Set(appliedRows.map((row) => row.filename))
const insertApplied = db.prepare(`
INSERT INTO schema_migrations (filename, applied_at)
VALUES (?, ?)
`)
for (const filename of files) {
if (applied.has(filename)) {
continue
}
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, filename), 'utf8')
db.exec(sql)
insertApplied.run(filename, new Date().toISOString())
}
}
const currentFilePath = fileURLToPath(import.meta.url)
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
runDatabaseMigrations()
}
+129
View File
@@ -0,0 +1,129 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
platform_order_id TEXT NOT NULL,
order_status TEXT NOT NULL DEFAULT 'created',
pay_status TEXT NOT NULL DEFAULT 'unpaid',
buyer_id TEXT NOT NULL DEFAULT '',
buyer_name TEXT NOT NULL DEFAULT '',
receiver_contact TEXT NOT NULL DEFAULT '',
total_amount INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CNY',
raw_payload_json TEXT NOT NULL DEFAULT '{}',
paid_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(platform, platform_order_id)
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
sku_code TEXT NOT NULL,
sku_name TEXT NOT NULL DEFAULT '',
quantity INTEGER NOT NULL DEFAULT 1,
spec_json TEXT NOT NULL DEFAULT '{}',
delivery_mode TEXT NOT NULL DEFAULT 'claim_link',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
CREATE TABLE IF NOT EXISTS cdk_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL DEFAULT '',
sku_code TEXT NOT NULL,
cdk_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'available',
reserved_by_task_id INTEGER,
invalid_reason TEXT NOT NULL DEFAULT '',
delivered_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(sku_code, cdk_code)
);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_sku_code ON cdk_inventory(sku_code);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_status ON cdk_inventory(status);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_reserved_task ON cdk_inventory(reserved_by_task_id);
CREATE TABLE IF NOT EXISTS delivery_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_item_id INTEGER NOT NULL,
platform_order_id TEXT NOT NULL,
task_no TEXT NOT NULL,
task_status TEXT NOT NULL DEFAULT 'pending_payment',
login_type TEXT NOT NULL DEFAULT '',
claim_token_id INTEGER,
reserved_cdk_id INTEGER,
browser_session_id TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
role_id TEXT NOT NULL DEFAULT '',
role_name TEXT NOT NULL DEFAULT '',
area TEXT NOT NULL DEFAULT '',
partition_name TEXT NOT NULL DEFAULT '',
result_code TEXT NOT NULL DEFAULT '',
result_message TEXT NOT NULL DEFAULT '',
screenshot_path TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT NOT NULL DEFAULT '',
retry_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
claimed_at TEXT,
role_confirmed_at TEXT,
redeemed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY(order_item_id) REFERENCES order_items(id) ON DELETE CASCADE,
FOREIGN KEY(reserved_cdk_id) REFERENCES cdk_inventory(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_tasks_task_no ON delivery_tasks(task_no);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_id ON delivery_tasks(order_id);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_item_id ON delivery_tasks(order_item_id);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_status ON delivery_tasks(task_status);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_platform_order_id ON delivery_tasks(platform_order_id);
CREATE TABLE IF NOT EXISTS claim_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
expired_at TEXT NOT NULL,
used_at TEXT,
max_use_count INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES delivery_tasks(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_tokens_token ON claim_tokens(token);
CREATE INDEX IF NOT EXISTS idx_claim_tokens_task_id ON claim_tokens(task_id);
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status);
CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
signature_valid INTEGER NOT NULL DEFAULT 0,
headers_json TEXT NOT NULL DEFAULT '{}',
query_json TEXT NOT NULL DEFAULT '{}',
body_json TEXT NOT NULL DEFAULT '{}',
processed INTEGER NOT NULL DEFAULT 0,
process_error TEXT NOT NULL DEFAULT '',
related_order_id INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_events_platform_key ON webhook_events(platform, event_key);
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed);
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at);
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'operator',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_admin_users_role ON admin_users(role);
CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status);
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER NOT NULL,
actor_username TEXT NOT NULL,
actor_role TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (actor_user_id) REFERENCES admin_users(id)
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_actor_user_id ON admin_audit_logs(actor_user_id);
CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_action ON admin_audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_target_type ON admin_audit_logs(target_type);
CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_created_at ON admin_audit_logs(created_at);
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS message_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
channel TEXT NOT NULL,
order_id INTEGER,
task_id INTEGER,
platform_order_id TEXT NOT NULL DEFAULT '',
recipient_key TEXT NOT NULL DEFAULT '',
message_content TEXT NOT NULL DEFAULT '',
claim_url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
request_url TEXT NOT NULL DEFAULT '',
request_headers_json TEXT NOT NULL DEFAULT '{}',
request_body_json TEXT NOT NULL DEFAULT '{}',
response_status INTEGER NOT NULL DEFAULT 0,
response_json TEXT NOT NULL DEFAULT '{}',
error_message TEXT NOT NULL DEFAULT '',
sent_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL,
FOREIGN KEY (task_id) REFERENCES delivery_tasks(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_message_deliveries_order_id ON message_deliveries(order_id);
CREATE INDEX IF NOT EXISTS idx_message_deliveries_task_id ON message_deliveries(task_id);
CREATE INDEX IF NOT EXISTS idx_message_deliveries_platform_order_id ON message_deliveries(platform_order_id);
CREATE INDEX IF NOT EXISTS idx_message_deliveries_status ON message_deliveries(status);
CREATE INDEX IF NOT EXISTS idx_message_deliveries_created_at ON message_deliveries(created_at);
+170
View File
@@ -0,0 +1,170 @@
import express from 'express'
import process from 'node:process'
import { runtimeConfig } from './config/runtime.js'
import { runDatabaseMigrations } from './db/migrate.js'
import adminRouter from './routes/admin.js'
import claimsRouter from './routes/claims.js'
import webhooksRouter from './routes/webhooks.js'
import { ensureAdminUsersBootstrapped } from './services/admin-auth-service.js'
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/ocr.js'
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session.js'
import tencentRouter from './routes/tencent.js'
const app = express()
const port = Number(runtimeConfig.server.port || 3000)
runDatabaseMigrations()
await ensureAdminUsersBootstrapped()
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
if (req.method === 'OPTIONS') {
res.sendStatus(204)
return
}
next()
})
app.use(express.json({ limit: '2mb' }))
app.use(express.urlencoded({ extended: true }))
app.get('/health', (_req, res) => {
res.json({
code: 0,
msg: 'ok',
time: Math.floor(Date.now() / 1000),
})
})
app.use('/api/v1/tencent', tencentRouter)
app.use('/api/v1/webhooks', webhooksRouter)
app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/admin', adminRouter)
const server = app.listen(port, () => {
console.log(`order-site-backend listening on http://127.0.0.1:${port}`)
void bootstrapBrowser()
void bootstrapOcr()
})
async function bootstrapBrowser() {
try {
const result = await warmupTencentBrowser()
if (!result.warmed) {
console.log('[startup] browser prewarm skipped')
return
}
console.log('[startup] browser prewarm ready')
} catch (error) {
if (isMissingPlaywrightBrowserError(error)) {
const installCommand = resolveBrowserInstallCommand()
console.warn(`[startup] browser prewarm skipped: ${formatErrorMessage(error)}`)
console.warn(`[startup] 请先安装浏览器依赖: ${installCommand}`)
if (String(runtimeConfig.browser.chromePath || '').trim()) {
console.warn('[startup] 当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在')
}
return
}
console.error('[startup] browser prewarm failed:', error)
}
}
async function bootstrapOcr() {
try {
await warmupLocalOcrWorker()
console.log('[startup] local OCR worker ready')
} catch (error) {
console.warn(`[startup] local OCR worker skipped: ${formatStartupError(error)}`)
}
}
function formatStartupError(error) {
if (error instanceof Error && error.message) {
return error.message.split('\n')[0].trim()
}
return String(error || '未知错误').trim()
}
function isMissingPlaywrightBrowserError(error) {
const message = formatErrorMessage(error).toLowerCase()
return (
message.includes("executable doesn't exist") ||
message.includes('please run the following command to download new browsers') ||
message.includes('browserType.launch'.toLowerCase())
&& message.includes('please run')
)
}
function formatErrorMessage(error) {
if (error instanceof Error && error.message) {
return error.message.split('\n')[0].trim()
}
return String(error || '未知错误').trim()
}
function resolveBrowserInstallCommand() {
if (process.platform === 'linux') {
return 'npm run browser:install:linux'
}
return 'npm run browser:install'
}
let shutdownStarted = false
async function shutdown(signal) {
if (shutdownStarted) {
return
}
shutdownStarted = true
console.log(`[shutdown] received ${signal}, closing browser sessions and HTTP server`)
try {
await closeAllTencentBrowserSessions({ markClosed: true })
} catch (error) {
console.error('[shutdown] failed to close browser sessions:', error)
}
try {
await closeLocalOcrWorker()
} catch (error) {
console.error('[shutdown] failed to close local OCR worker:', error)
}
if (!server.listening) {
return
}
await new Promise((resolve) => {
server.close((error) => {
if (error) {
console.error('[shutdown] failed to close HTTP server:', error)
process.exitCode = 1
}
resolve()
})
})
}
process.on('SIGINT', () => {
void shutdown('SIGINT')
})
process.on('SIGTERM', () => {
void shutdown('SIGTERM')
})
@@ -0,0 +1,86 @@
import { getDb } from '../db/client.js'
export function createAdminAuditLog(input) {
const result = getDb().prepare(`
INSERT INTO admin_audit_logs (
actor_user_id,
actor_username,
actor_role,
action,
target_type,
target_id,
payload_json,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.actorUserId,
input.actorUsername,
input.actorRole,
input.action,
input.targetType,
input.targetId,
input.payloadJson,
input.createdAt,
)
return getAdminAuditLogById(Number(result.lastInsertRowid))
}
export function getAdminAuditLogById(logId) {
return getDb().prepare('SELECT * FROM admin_audit_logs WHERE id = ? LIMIT 1').get(logId) || null
}
export function listAdminAuditLogs(query = {}) {
const conditions = []
const values = []
if (query.actorUsername) {
conditions.push('actor_username = ?')
values.push(query.actorUsername)
}
if (query.action) {
conditions.push('action = ?')
values.push(query.action)
}
if (query.targetType) {
conditions.push('target_type = ?')
values.push(query.targetType)
}
if (query.dateFrom) {
conditions.push('created_at >= ?')
values.push(query.dateFrom)
}
if (query.dateTo) {
conditions.push('created_at <= ?')
values.push(query.dateTo)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 20
const offset = (page - 1) * pageSize
const db = getDb()
const items = db.prepare(`
SELECT *
FROM admin_audit_logs
${whereClause}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...values, pageSize, offset)
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM admin_audit_logs
${whereClause}
`).get(...values)
return {
items,
total: Number(totalRow?.total || 0),
}
}
@@ -0,0 +1,117 @@
import { getDb } from '../db/client.js'
export function getAdminUserByUsername(username) {
return getDb().prepare('SELECT * FROM admin_users WHERE username = ? LIMIT 1').get(username) || null
}
export function getAdminUserById(userId) {
return getDb().prepare('SELECT * FROM admin_users WHERE id = ? LIMIT 1').get(userId) || null
}
export function createAdminUser(input) {
const result = getDb().prepare(`
INSERT INTO admin_users (
username,
password_hash,
role,
status,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
input.username,
input.passwordHash,
input.role,
input.status,
input.createdAt,
input.updatedAt,
)
return getAdminUserById(Number(result.lastInsertRowid))
}
export function listAdminUsers(query = {}) {
const conditions = []
const values = []
if (query.username) {
conditions.push('username LIKE ?')
values.push(`%${query.username}%`)
}
if (query.role) {
conditions.push('role = ?')
values.push(query.role)
}
if (query.status) {
conditions.push('status = ?')
values.push(query.status)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const page = Number(query.page) || 1
const pageSize = Number(query.pageSize) || 20
const offset = (page - 1) * pageSize
const db = getDb()
const items = db.prepare(`
SELECT *
FROM admin_users
${whereClause}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...values, pageSize, offset)
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM admin_users
${whereClause}
`).get(...values)
return {
items,
total: Number(totalRow?.total || 0),
}
}
export function updateAdminUser(userId, patch = {}) {
const current = getAdminUserById(userId)
if (!current) {
return null
}
const next = {
username: Object.prototype.hasOwnProperty.call(patch, 'username') ? patch.username : current.username,
password_hash: Object.prototype.hasOwnProperty.call(patch, 'password_hash') ? patch.password_hash : current.password_hash,
role: Object.prototype.hasOwnProperty.call(patch, 'role') ? patch.role : current.role,
status: Object.prototype.hasOwnProperty.call(patch, 'status') ? patch.status : current.status,
updated_at: Object.prototype.hasOwnProperty.call(patch, 'updated_at') ? patch.updated_at : current.updated_at,
}
getDb().prepare(`
UPDATE admin_users
SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ?
WHERE id = ?
`).run(
next.username,
next.password_hash,
next.role,
next.status,
next.updated_at,
userId,
)
return getAdminUserById(userId)
}
export function countActiveAdminUsers() {
const row = getDb().prepare(`
SELECT COUNT(*) AS total
FROM admin_users
WHERE role = 'admin' AND status = 'active'
`).get()
return Number(row?.total || 0)
}
+143
View File
@@ -0,0 +1,143 @@
import { getDb } from '../db/client.js'
export function findFirstAvailableCdkBySkuCode(skuCode) {
return getDb().prepare(`
SELECT *
FROM cdk_inventory
WHERE sku_code = ? AND status = 'available'
ORDER BY id ASC
LIMIT 1
`).get(skuCode) || null
}
export function assignReservedCdk(cdkId, taskId, updatedAt) {
getDb().prepare(`
UPDATE cdk_inventory
SET
status = 'reserved',
reserved_by_task_id = ?,
updated_at = ?
WHERE id = ? AND status = 'available'
`).run(taskId, updatedAt, cdkId)
return getCdkById(cdkId)
}
export function getCdkById(cdkId) {
return getDb().prepare('SELECT * FROM cdk_inventory WHERE id = ? LIMIT 1').get(cdkId) || null
}
export function markCdkDelivered(cdkId, deliveredAt) {
getDb().prepare(`
UPDATE cdk_inventory
SET
status = 'delivered',
delivered_at = ?,
updated_at = ?
WHERE id = ?
`).run(deliveredAt, deliveredAt, cdkId)
return getCdkById(cdkId)
}
export function listCdks({
page = 1,
pageSize = 20,
skuCode = '',
status = '',
batchNo = '',
} = {}) {
const offset = (page - 1) * pageSize
const filters = []
const params = []
if (skuCode) {
filters.push('sku_code = ?')
params.push(skuCode)
}
if (status) {
filters.push('status = ?')
params.push(status)
}
if (batchNo) {
filters.push('batch_no LIKE ?')
params.push(`%${batchNo}%`)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const db = getDb()
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM cdk_inventory
${whereClause}
`).get(...params)
const items = db.prepare(`
SELECT *
FROM cdk_inventory
${whereClause}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset)
return {
items,
total: Number(totalRow?.total || 0),
}
}
export function createCdks(rows) {
const db = getDb()
const stmt = db.prepare(`
INSERT OR IGNORE INTO cdk_inventory (
batch_no,
sku_code,
cdk_code,
status,
reserved_by_task_id,
invalid_reason,
delivered_at,
created_at,
updated_at
) VALUES (?, ?, ?, 'available', NULL, '', NULL, ?, ?)
`)
let created = 0
for (const row of rows) {
const result = stmt.run(row.batchNo, row.skuCode, row.cdkCode, row.createdAt, row.updatedAt)
if (Number(result.changes || 0) > 0) {
created += 1
}
}
return created
}
export function releaseReservedCdk(cdkId, updatedAt) {
getDb().prepare(`
UPDATE cdk_inventory
SET
status = 'available',
reserved_by_task_id = NULL,
updated_at = ?
WHERE id = ? AND status = 'reserved'
`).run(updatedAt, cdkId)
return getCdkById(cdkId)
}
export function invalidateCdk(cdkId, invalidReason, updatedAt) {
getDb().prepare(`
UPDATE cdk_inventory
SET
status = 'invalid',
invalid_reason = ?,
updated_at = ?
WHERE id = ? AND status = 'available'
`).run(invalidReason, updatedAt, cdkId)
return getCdkById(cdkId)
}
@@ -0,0 +1,69 @@
import { getDb } from '../db/client.js'
export function createClaimToken(input) {
const result = getDb().prepare(`
INSERT INTO claim_tokens (
task_id,
token,
status,
expired_at,
used_at,
max_use_count,
used_count,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.taskId,
input.token,
input.status,
input.expiredAt,
input.usedAt,
input.maxUseCount,
input.usedCount,
input.createdAt,
input.updatedAt,
)
return getClaimTokenById(Number(result.lastInsertRowid))
}
export function getClaimTokenById(tokenId) {
return getDb().prepare('SELECT * FROM claim_tokens WHERE id = ? LIMIT 1').get(tokenId) || null
}
export function findClaimTokenByToken(token) {
return getDb().prepare('SELECT * FROM claim_tokens WHERE token = ? LIMIT 1').get(token) || null
}
export function updateClaimToken(tokenId, patch) {
const current = getClaimTokenById(tokenId)
if (!current) {
return null
}
const next = { ...current, ...patch }
getDb().prepare(`
UPDATE claim_tokens
SET
status = ?,
expired_at = ?,
used_at = ?,
max_use_count = ?,
used_count = ?,
updated_at = ?
WHERE id = ?
`).run(
next.status,
next.expired_at,
next.used_at,
next.max_use_count,
next.used_count,
next.updated_at,
tokenId,
)
return getClaimTokenById(tokenId)
}
@@ -0,0 +1,99 @@
import { getDb } from '../db/client.js'
export function createMessageDelivery(input) {
const result = getDb().prepare(`
INSERT INTO message_deliveries (
platform,
channel,
order_id,
task_id,
platform_order_id,
recipient_key,
message_content,
claim_url,
status,
request_url,
request_headers_json,
request_body_json,
response_status,
response_json,
error_message,
sent_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.platform,
input.channel,
input.orderId,
input.taskId,
input.platformOrderId,
input.recipientKey,
input.messageContent,
input.claimUrl,
input.status,
input.requestUrl,
input.requestHeadersJson,
input.requestBodyJson,
input.responseStatus,
input.responseJson,
input.errorMessage,
input.sentAt,
input.createdAt,
input.updatedAt,
)
return getMessageDeliveryById(Number(result.lastInsertRowid))
}
export function getMessageDeliveryById(deliveryId) {
return getDb().prepare('SELECT * FROM message_deliveries WHERE id = ? LIMIT 1').get(deliveryId) || null
}
export function updateMessageDelivery(deliveryId, patch = {}) {
const current = getMessageDeliveryById(deliveryId)
if (!current) {
return null
}
const next = { ...current, ...patch }
getDb().prepare(`
UPDATE message_deliveries
SET
status = ?,
request_url = ?,
request_headers_json = ?,
request_body_json = ?,
response_status = ?,
response_json = ?,
error_message = ?,
sent_at = ?,
updated_at = ?
WHERE id = ?
`).run(
next.status,
next.request_url,
next.request_headers_json,
next.request_body_json,
next.response_status,
next.response_json,
next.error_message,
next.sent_at,
next.updated_at,
deliveryId,
)
return getMessageDeliveryById(deliveryId)
}
export function findLatestSuccessfulMessageDeliveryByTask(taskId, channel) {
return getDb().prepare(`
SELECT *
FROM message_deliveries
WHERE task_id = ? AND channel = ? AND status = 'success'
ORDER BY id DESC
LIMIT 1
`).get(taskId, channel) || null
}
@@ -0,0 +1,80 @@
import { getDb } from '../db/client.js'
export function listOrderItemsByOrderId(orderId) {
return getDb().prepare(`
SELECT *
FROM order_items
WHERE order_id = ?
ORDER BY id ASC
`).all(orderId)
}
export function replaceOrderItems(orderId, items) {
const db = getDb()
const existingItems = listOrderItemsByOrderId(orderId)
const updateStatement = db.prepare(`
UPDATE order_items
SET
sku_code = ?,
sku_name = ?,
quantity = ?,
spec_json = ?,
delivery_mode = ?,
updated_at = ?
WHERE id = ?
`)
const insertStatement = db.prepare(`
INSERT INTO order_items (
order_id,
sku_code,
sku_name,
quantity,
spec_json,
delivery_mode,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
for (let index = 0; index < items.length; index += 1) {
const item = items[index]
const existingItem = existingItems[index] || null
if (existingItem) {
updateStatement.run(
item.skuCode,
item.skuName,
item.quantity,
item.specJson,
item.deliveryMode,
item.updatedAt,
existingItem.id,
)
continue
}
insertStatement.run(
orderId,
item.skuCode,
item.skuName,
item.quantity,
item.specJson,
item.deliveryMode,
item.createdAt,
item.updatedAt,
)
}
if (existingItems.length > items.length) {
const redundantIds = existingItems.slice(items.length).map((item) => item.id)
const placeholders = redundantIds.map(() => '?').join(', ')
db.prepare(`DELETE FROM order_items WHERE order_id = ? AND id IN (${placeholders})`).run(orderId, ...redundantIds)
}
return listOrderItemsByOrderId(orderId)
}
export function getOrderItemById(orderItemId) {
return getDb().prepare('SELECT * FROM order_items WHERE id = ? LIMIT 1').get(orderItemId) || null
}
+144
View File
@@ -0,0 +1,144 @@
import { getDb } from '../db/client.js'
export function findOrderByPlatformOrderId(platform, platformOrderId) {
return getDb().prepare(`
SELECT *
FROM orders
WHERE platform = ? AND platform_order_id = ?
LIMIT 1
`).get(platform, platformOrderId) || null
}
export function createOrder(input) {
const result = getDb().prepare(`
INSERT INTO orders (
platform,
platform_order_id,
order_status,
pay_status,
buyer_id,
buyer_name,
receiver_contact,
total_amount,
currency,
raw_payload_json,
paid_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.platform,
input.platformOrderId,
input.orderStatus,
input.payStatus,
input.buyerId,
input.buyerName,
input.receiverContact,
input.totalAmount,
input.currency,
input.rawPayloadJson,
input.paidAt,
input.createdAt,
input.updatedAt,
)
return getOrderById(Number(result.lastInsertRowid))
}
export function updateOrder(orderId, input) {
getDb().prepare(`
UPDATE orders
SET
order_status = ?,
pay_status = ?,
buyer_id = ?,
buyer_name = ?,
receiver_contact = ?,
total_amount = ?,
currency = ?,
raw_payload_json = ?,
paid_at = ?,
updated_at = ?
WHERE id = ?
`).run(
input.orderStatus,
input.payStatus,
input.buyerId,
input.buyerName,
input.receiverContact,
input.totalAmount,
input.currency,
input.rawPayloadJson,
input.paidAt,
input.updatedAt,
orderId,
)
return getOrderById(orderId)
}
export function getOrderById(orderId) {
return getDb().prepare('SELECT * FROM orders WHERE id = ? LIMIT 1').get(orderId) || null
}
export function listOrders({
page = 1,
pageSize = 20,
platformOrderId = '',
payStatus = '',
skuCode = '',
dateFrom = '',
dateTo = '',
} = {}) {
const offset = (page - 1) * pageSize
const filters = []
const params = []
if (platformOrderId) {
filters.push('o.platform_order_id LIKE ?')
params.push(`%${platformOrderId}%`)
}
if (payStatus) {
filters.push('o.pay_status = ?')
params.push(payStatus)
}
if (skuCode) {
filters.push('EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = ?)')
params.push(skuCode)
}
if (dateFrom) {
filters.push('o.created_at >= ?')
params.push(dateFrom)
}
if (dateTo) {
filters.push('o.created_at <= ?')
params.push(dateTo)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const db = getDb()
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM orders o
${whereClause}
`).get(...params)
const items = db.prepare(`
SELECT
o.*,
(SELECT COUNT(*) FROM delivery_tasks dt WHERE dt.order_id = o.id) AS task_count
FROM orders o
${whereClause}
ORDER BY o.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset)
return {
items,
total: Number(totalRow?.total || 0),
}
}
+228
View File
@@ -0,0 +1,228 @@
import { getDb } from '../db/client.js'
export function listTasksByOrderId(orderId) {
return getDb().prepare(`
SELECT *
FROM delivery_tasks
WHERE order_id = ?
ORDER BY id ASC
`).all(orderId)
}
export function createTask(input) {
const result = getDb().prepare(`
INSERT INTO delivery_tasks (
order_id,
order_item_id,
platform_order_id,
task_no,
task_status,
login_type,
claim_token_id,
reserved_cdk_id,
browser_session_id,
nickname,
role_id,
role_name,
area,
partition_name,
result_code,
result_message,
screenshot_path,
artifacts_json,
last_error,
retry_count,
expires_at,
claimed_at,
role_confirmed_at,
redeemed_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.orderId,
input.orderItemId,
input.platformOrderId,
input.taskNo,
input.taskStatus,
input.loginType,
input.claimTokenId,
input.reservedCdkId,
input.browserSessionId,
input.nickname,
input.roleId,
input.roleName,
input.area,
input.partitionName,
input.resultCode,
input.resultMessage,
input.screenshotPath,
input.artifactsJson,
input.lastError,
input.retryCount,
input.expiresAt,
input.claimedAt,
input.roleConfirmedAt,
input.redeemedAt,
input.createdAt,
input.updatedAt,
)
return getTaskById(Number(result.lastInsertRowid))
}
export function updateTask(taskId, patch) {
const current = getTaskById(taskId)
if (!current) {
return null
}
const next = { ...current, ...patch }
getDb().prepare(`
UPDATE delivery_tasks
SET
task_status = ?,
login_type = ?,
claim_token_id = ?,
reserved_cdk_id = ?,
browser_session_id = ?,
nickname = ?,
role_id = ?,
role_name = ?,
area = ?,
partition_name = ?,
result_code = ?,
result_message = ?,
screenshot_path = ?,
artifacts_json = ?,
last_error = ?,
retry_count = ?,
expires_at = ?,
claimed_at = ?,
role_confirmed_at = ?,
redeemed_at = ?,
updated_at = ?
WHERE id = ?
`).run(
next.task_status,
next.login_type,
next.claim_token_id,
next.reserved_cdk_id,
next.browser_session_id,
next.nickname,
next.role_id,
next.role_name,
next.area,
next.partition_name,
next.result_code,
next.result_message,
next.screenshot_path,
next.artifacts_json,
next.last_error,
next.retry_count,
next.expires_at,
next.claimed_at,
next.role_confirmed_at,
next.redeemed_at,
next.updated_at,
taskId,
)
return getTaskById(taskId)
}
export function getTaskById(taskId) {
return getDb().prepare('SELECT * FROM delivery_tasks WHERE id = ? LIMIT 1').get(taskId) || null
}
export function findTaskByClaimTokenId(claimTokenId) {
return getDb().prepare(`
SELECT *
FROM delivery_tasks
WHERE claim_token_id = ?
LIMIT 1
`).get(claimTokenId) || null
}
export function listTasks({
page = 1,
pageSize = 20,
status = '',
platformOrderId = '',
taskNo = '',
skuCode = '',
roleId = '',
dateFrom = '',
dateTo = '',
} = {}) {
const offset = (page - 1) * pageSize
const filters = []
const params = []
if (status) {
filters.push('dt.task_status = ?')
params.push(status)
}
if (platformOrderId) {
filters.push('dt.platform_order_id LIKE ?')
params.push(`%${platformOrderId}%`)
}
if (taskNo) {
filters.push('dt.task_no LIKE ?')
params.push(`%${taskNo}%`)
}
if (skuCode) {
filters.push('oi.sku_code = ?')
params.push(skuCode)
}
if (roleId) {
filters.push('dt.role_id LIKE ?')
params.push(`%${roleId}%`)
}
if (dateFrom) {
filters.push('dt.created_at >= ?')
params.push(dateFrom)
}
if (dateTo) {
filters.push('dt.created_at <= ?')
params.push(dateTo)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const db = getDb()
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM delivery_tasks dt
LEFT JOIN order_items oi ON oi.id = dt.order_item_id
${whereClause}
`).get(...params)
const items = db.prepare(`
SELECT
dt.*,
oi.sku_code,
oi.sku_name,
ci.cdk_code,
ct.token AS claim_token
FROM delivery_tasks dt
LEFT JOIN order_items oi ON oi.id = dt.order_item_id
LEFT JOIN cdk_inventory ci ON ci.id = dt.reserved_cdk_id
LEFT JOIN claim_tokens ct ON ct.id = dt.claim_token_id
${whereClause}
ORDER BY dt.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset)
return {
items,
total: Number(totalRow?.total || 0),
}
}
@@ -0,0 +1,127 @@
import { getDb } from '../db/client.js'
export function createWebhookEvent(input) {
const result = getDb().prepare(`
INSERT INTO webhook_events (
platform,
event_type,
event_key,
signature_valid,
headers_json,
query_json,
body_json,
processed,
process_error,
related_order_id,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.platform,
input.eventType,
input.eventKey,
input.signatureValid ? 1 : 0,
input.headersJson,
input.queryJson,
input.bodyJson,
input.processed ? 1 : 0,
input.processError,
input.relatedOrderId,
input.createdAt,
)
return getWebhookEventById(Number(result.lastInsertRowid))
}
export function updateWebhookEvent(eventId, patch) {
const current = getWebhookEventById(eventId)
if (!current) {
return null
}
const next = { ...current, ...patch }
getDb().prepare(`
UPDATE webhook_events
SET
processed = ?,
process_error = ?,
related_order_id = ?
WHERE id = ?
`).run(next.processed ? 1 : 0, next.process_error, next.related_order_id, eventId)
return getWebhookEventById(eventId)
}
export function getWebhookEventById(eventId) {
return getDb().prepare('SELECT * FROM webhook_events WHERE id = ? LIMIT 1').get(eventId) || null
}
export function listWebhookEvents({
page = 1,
pageSize = 20,
platform = '',
processed = '',
relatedOrderId = '',
dateFrom = '',
dateTo = '',
} = {}) {
const offset = (page - 1) * pageSize
const filters = []
const params = []
if (platform) {
filters.push('platform = ?')
params.push(platform)
}
if (processed === '0' || processed === '1') {
filters.push('processed = ?')
params.push(Number(processed))
}
if (relatedOrderId) {
filters.push('related_order_id = ?')
params.push(Number(relatedOrderId))
}
if (dateFrom) {
filters.push('created_at >= ?')
params.push(dateFrom)
}
if (dateTo) {
filters.push('created_at <= ?')
params.push(dateTo)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const db = getDb()
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM webhook_events
${whereClause}
`).get(...params)
const items = db.prepare(`
SELECT *
FROM webhook_events
${whereClause}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset)
return {
items,
total: Number(totalRow?.total || 0),
}
}
export function listWebhookEventsByOrderId(orderId) {
return getDb().prepare(`
SELECT *
FROM webhook_events
WHERE related_order_id = ?
ORDER BY id DESC
`).all(orderId)
}
+432
View File
@@ -0,0 +1,432 @@
import { Router } from 'express'
import {
closeAdminTask,
createAdminCdk,
getAdminCdks,
getAdminDashboardSummary,
getAdminOrderDetail,
getAdminOrders,
getAdminTaskDetail,
getAdminTaskScreenshotPath,
getAdminTasks,
getAdminWebhookEventDetail,
getAdminWebhookEvents,
importAdminCdks,
invalidateAdminCdk,
markAdminTaskManualReview,
replayAdminWebhookEvent,
releaseAdminInventoryCdk,
regenerateAdminTaskClaimLink,
releaseAdminTaskCdk,
retryAdminTask,
} from '../services/admin-service.js'
import {
createManagedAdminUser,
getAdminUserList,
getAdminSessionSummary,
loginAdmin,
resetManagedAdminUserPassword,
requireAdminRole,
updateManagedAdminUserRole,
updateManagedAdminUserStatus,
verifyAdminSessionToken,
} from '../services/admin-auth-service.js'
import { getAdminAuditLogs, writeAdminAuditLog } from '../services/admin-audit-service.js'
import { buildNotFoundPayload, buildSuccessPayload, createHttpError, sendRouteError } from '../utils/http.js'
const router = Router()
router.post('/auth/login', (req, res) => {
try {
const data = loginAdmin(req.body?.username, req.body?.password)
res.json(buildSuccessPayload(data, '登录成功'))
} catch (error) {
sendRouteError(res, error, '后台登录失败', '[admin/auth/login]')
}
})
router.get('/auth/session', (req, res) => {
try {
const data = getAdminSessionSummary(extractBearerToken(req))
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取后台登录态失败', '[admin/auth/session]')
}
})
router.post('/auth/logout', (_req, res) => {
res.json(buildSuccessPayload({ success: true }, '已退出登录'))
})
router.use((req, res, next) => {
try {
req.adminSession = verifyAdminSessionToken(extractBearerToken(req))
next()
} catch (error) {
sendRouteError(res, error, '后台鉴权失败', '[admin/auth]')
}
})
router.get('/dashboard/summary', (req, res) => {
try {
const data = getAdminDashboardSummary()
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取后台概览失败', '[admin/dashboard/summary]')
}
})
router.get('/users', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = getAdminUserList(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取后台用户列表失败', '[admin/users]')
}
})
router.post('/users', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = createManagedAdminUser(req.body)
writeAdminAuditLog(req.adminSession, {
action: 'admin_user_created',
targetType: 'admin_user',
targetId: String(data.user.userId),
data: {
username: data.user.username,
role: data.user.role,
status: data.user.status,
},
})
res.json(buildSuccessPayload(data, '后台用户已创建'))
} catch (error) {
sendRouteError(res, error, '创建后台用户失败', '[admin/users:create]')
}
})
router.post('/users/:userId/role', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = updateManagedAdminUserRole(req.params.userId, req.body, req.adminSession)
writeAdminAuditLog(req.adminSession, {
action: 'admin_user_role_updated',
targetType: 'admin_user',
targetId: String(data.user.userId),
data: {
username: data.user.username,
role: data.user.role,
},
})
res.json(buildSuccessPayload(data, '用户角色已更新'))
} catch (error) {
sendRouteError(res, error, '更新用户角色失败', '[admin/users/:userId/role]')
}
})
router.post('/users/:userId/status', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = updateManagedAdminUserStatus(req.params.userId, req.body, req.adminSession)
writeAdminAuditLog(req.adminSession, {
action: 'admin_user_status_updated',
targetType: 'admin_user',
targetId: String(data.user.userId),
data: {
username: data.user.username,
status: data.user.status,
},
})
res.json(buildSuccessPayload(data, '用户状态已更新'))
} catch (error) {
sendRouteError(res, error, '更新用户状态失败', '[admin/users/:userId/status]')
}
})
router.post('/users/:userId/reset-password', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = resetManagedAdminUserPassword(req.params.userId, req.body)
writeAdminAuditLog(req.adminSession, {
action: 'admin_user_password_reset',
targetType: 'admin_user',
targetId: String(data.user.userId),
data: {
username: data.user.username,
},
})
res.json(buildSuccessPayload(data, '用户密码已重置'))
} catch (error) {
sendRouteError(res, error, '重置用户密码失败', '[admin/users/:userId/reset-password]')
}
})
router.get('/audit-logs', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = getAdminAuditLogs(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取操作审计日志失败', '[admin/audit-logs]')
}
})
router.get('/orders', (req, res) => {
try {
const data = getAdminOrders(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取订单列表失败', '[admin/orders]')
}
})
router.get('/orders/:orderId', (req, res) => {
try {
const data = getAdminOrderDetail(req.params.orderId)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取订单详情失败', '[admin/orders/:orderId]')
}
})
router.get('/tasks', (req, res) => {
try {
const data = getAdminTasks(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取任务列表失败', '[admin/tasks]')
}
})
router.get('/tasks/:taskId', (req, res) => {
try {
const data = getAdminTaskDetail(req.params.taskId)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取任务详情失败', '[admin/tasks/:taskId]')
}
})
router.get('/tasks/:taskId/screenshot', (req, res) => {
try {
const screenshotPath = getAdminTaskScreenshotPath(req.params.taskId)
res.sendFile(screenshotPath)
} catch (error) {
sendRouteError(res, error, '读取任务截图失败', '[admin/tasks/:taskId/screenshot]')
}
})
router.post('/tasks/:taskId/retry', (req, res) => {
try {
const data = retryAdminTask(req.params.taskId)
res.json(buildSuccessPayload(data, '任务已重试'))
} catch (error) {
sendRouteError(res, error, '重试任务失败', '[admin/tasks/:taskId/retry]')
}
})
router.post('/tasks/:taskId/release-cdk', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = releaseAdminTaskCdk(req.params.taskId)
writeAdminAuditLog(req.adminSession, {
action: 'task_release_cdk',
targetType: 'task',
targetId: String(req.params.taskId),
data: {
taskId: data.task.taskId,
taskNo: data.task.taskNo,
reservedCdkId: data.task.reservedCdkId,
},
})
res.json(buildSuccessPayload(data, 'CDK 已释放'))
} catch (error) {
sendRouteError(res, error, '释放 CDK 失败', '[admin/tasks/:taskId/release-cdk]')
}
})
router.post('/tasks/:taskId/regenerate-claim-link', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = regenerateAdminTaskClaimLink(req.params.taskId)
writeAdminAuditLog(req.adminSession, {
action: 'task_regenerate_claim_link',
targetType: 'task',
targetId: String(req.params.taskId),
data: {
taskId: data.task.taskId,
taskNo: data.task.taskNo,
claimTokenId: data.task.claimTokenId,
},
})
res.json(buildSuccessPayload(data, '领取链接已重新生成'))
} catch (error) {
sendRouteError(res, error, '重新生成领取链接失败', '[admin/tasks/:taskId/regenerate-claim-link]')
}
})
router.post('/tasks/:taskId/close', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = closeAdminTask(req.params.taskId)
writeAdminAuditLog(req.adminSession, {
action: 'task_closed',
targetType: 'task',
targetId: String(req.params.taskId),
data: {
taskId: data.task.taskId,
taskNo: data.task.taskNo,
status: data.task.status,
},
})
res.json(buildSuccessPayload(data, '任务已关闭'))
} catch (error) {
sendRouteError(res, error, '关闭任务失败', '[admin/tasks/:taskId/close]')
}
})
router.post('/tasks/:taskId/mark-manual-review', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = markAdminTaskManualReview(req.params.taskId)
writeAdminAuditLog(req.adminSession, {
action: 'task_mark_manual_review',
targetType: 'task',
targetId: String(req.params.taskId),
data: {
taskId: data.task.taskId,
taskNo: data.task.taskNo,
status: data.task.status,
},
})
res.json(buildSuccessPayload(data, '任务已转人工处理'))
} catch (error) {
sendRouteError(res, error, '标记人工处理失败', '[admin/tasks/:taskId/mark-manual-review]')
}
})
router.get('/cdks', (req, res) => {
try {
const data = getAdminCdks(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取 CDK 列表失败', '[admin/cdks]')
}
})
router.post('/cdks', (req, res) => {
try {
const data = createAdminCdk(req.body)
res.json(buildSuccessPayload(data, 'CDK 已新增'))
} catch (error) {
sendRouteError(res, error, '新增 CDK 失败', '[admin/cdks]')
}
})
router.post('/cdks/import', (req, res) => {
try {
const data = importAdminCdks(req.body)
res.json(buildSuccessPayload(data, '导入完成'))
} catch (error) {
sendRouteError(res, error, '导入 CDK 失败', '[admin/cdks/import]')
}
})
router.post('/cdks/:cdkId/release', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = releaseAdminInventoryCdk(req.params.cdkId)
writeAdminAuditLog(req.adminSession, {
action: 'inventory_cdk_released',
targetType: 'cdk',
targetId: String(req.params.cdkId),
data: {
cdkId: data.cdk?.cdkId,
skuCode: data.cdk?.skuCode,
cdkCode: data.cdk?.cdkCode,
},
})
res.json(buildSuccessPayload(data, 'CDK 已释放'))
} catch (error) {
sendRouteError(res, error, '释放库存 CDK 失败', '[admin/cdks/:cdkId/release]')
}
})
router.post('/cdks/:cdkId/invalidate', (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = invalidateAdminCdk(req.params.cdkId, req.body)
writeAdminAuditLog(req.adminSession, {
action: 'inventory_cdk_invalidated',
targetType: 'cdk',
targetId: String(req.params.cdkId),
data: {
cdkId: data.cdk?.cdkId,
skuCode: data.cdk?.skuCode,
invalidReason: data.cdk?.invalidReason,
},
})
res.json(buildSuccessPayload(data, 'CDK 已作废'))
} catch (error) {
sendRouteError(res, error, '作废 CDK 失败', '[admin/cdks/:cdkId/invalidate]')
}
})
router.get('/webhook-events', (req, res) => {
try {
const data = getAdminWebhookEvents(req.query)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取 webhook 列表失败', '[admin/webhook-events]')
}
})
router.get('/webhook-events/:eventId', (req, res) => {
try {
const data = getAdminWebhookEventDetail(req.params.eventId)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '读取 webhook 详情失败', '[admin/webhook-events/:eventId]')
}
})
router.post('/webhook-events/:eventId/replay', async (req, res) => {
try {
requireAdminRole(req.adminSession, ['admin'])
const data = await replayAdminWebhookEvent(req.params.eventId)
writeAdminAuditLog(req.adminSession, {
action: 'webhook_replayed',
targetType: 'webhook_event',
targetId: String(req.params.eventId),
data: {
eventId: data.eventId,
replayed: data.replayed,
},
})
res.json(buildSuccessPayload(data, 'Webhook 已重放'))
} catch (error) {
sendRouteError(res, error, '重放 webhook 失败', '[admin/webhook-events/:eventId/replay]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
function extractBearerToken(req) {
const authorization = String(req.headers.authorization || '').trim()
const matched = authorization.match(/^Bearer\s+(.+)$/i)
if (!matched) {
throw createHttpError('未登录或登录已失效', {
statusCode: 401,
errorCode: 'admin_auth_required',
})
}
return matched[1]
}
+73
View File
@@ -0,0 +1,73 @@
import { Router } from 'express'
import {
confirmClaimRole,
createClaimSession,
getClaimDetail,
getClaimScreenshotPath,
getClaimSessionSummary,
redeemClaimTask,
} from '../services/claim-session-service.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
router.get('/:token', async (req, res) => {
try {
const data = await getClaimDetail(req.params.token)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '查询领取详情失败', '[claims/:token]')
}
})
router.post('/:token/session', async (req, res) => {
try {
const data = await createClaimSession(req.params.token, req.body)
res.json(buildSuccessPayload(data, '浏览器会话已创建'))
} catch (error) {
sendRouteError(res, error, '创建领取会话失败', '[claims/:token/session]')
}
})
router.get('/:token/session/summary', async (req, res) => {
try {
const data = await getClaimSessionSummary(req.params.token)
res.json(buildSuccessPayload(data, 'ok'))
} catch (error) {
sendRouteError(res, error, '查询领取会话摘要失败', '[claims/:token/session/summary]')
}
})
router.post('/:token/confirm-role', async (req, res) => {
try {
const data = await confirmClaimRole(req.params.token)
res.json(buildSuccessPayload(data, '角色已确认'))
} catch (error) {
sendRouteError(res, error, '确认角色失败', '[claims/:token/confirm-role]')
}
})
router.post('/:token/redeem', async (req, res) => {
try {
const data = await redeemClaimTask(req.params.token)
res.json(buildSuccessPayload(data, '兑换完成'))
} catch (error) {
sendRouteError(res, error, '执行领取兑换失败', '[claims/:token/redeem]')
}
})
router.get('/:token/screenshot', async (req, res) => {
try {
const screenshotPath = await getClaimScreenshotPath(req.params.token)
res.sendFile(screenshotPath)
} catch (error) {
sendRouteError(res, error, '读取领取截图失败', '[claims/:token/screenshot]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
+87
View File
@@ -0,0 +1,87 @@
import { Router } from 'express'
import {
closeTencentBrowserSession,
createTencentBrowserSession,
getTencentBrowserSession,
getTencentBrowserSessionSummary,
getTencentBrowserSessionScreenshotPath,
reloadTencentBrowserSession,
redeemTencentBrowserSession,
} from '../services/session.js'
import {
buildNotFoundPayload,
buildSuccessPayload,
sendRouteError,
} from '../utils/http.js'
const router = Router()
router.post('/browser/session', async (req, res) => {
try {
const data = await createTencentBrowserSession(req.body)
res.json(buildSuccessPayload(data, '浏览器会话已创建'))
} catch (error) {
sendRouteError(res, error, '创建浏览器会话失败', '[browser/session]')
}
})
router.get('/browser/session/:sessionId', async (req, res) => {
try {
const data = await getTencentBrowserSession(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || 'ok'))
} catch (error) {
sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]')
}
})
router.get('/browser/session/:sessionId/summary', async (req, res) => {
try {
const data = await getTencentBrowserSessionSummary(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || 'ok'))
} catch (error) {
sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]')
}
})
router.post('/browser/session/:sessionId/refresh', async (req, res) => {
try {
const data = await reloadTencentBrowserSession(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新'))
} catch (error) {
sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]')
}
})
router.post('/browser/session/:sessionId/redeem', async (req, res) => {
try {
const data = await redeemTencentBrowserSession(req.params.sessionId, req.body)
res.json(buildSuccessPayload(data, data.notice || '兑换完成'))
} catch (error) {
sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]')
}
})
router.get('/browser/session/:sessionId/screenshot', async (req, res) => {
try {
const screenshotPath = await getTencentBrowserSessionScreenshotPath(req.params.sessionId)
res.sendFile(screenshotPath)
} catch (error) {
sendRouteError(res, error, '读取兑换截图失败', '[browser/session/:sessionId/screenshot]')
}
})
router.delete('/browser/session/:sessionId', async (req, res) => {
try {
const data = await closeTencentBrowserSession(req.params.sessionId)
res.json(buildSuccessPayload(data, '浏览器会话已关闭'))
} catch (error) {
sendRouteError(res, error, '关闭浏览器会话失败', '[browser/session/:sessionId]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
+25
View File
@@ -0,0 +1,25 @@
import { Router } from 'express'
import { processAgisoTradeWebhook } from '../services/webhook-service.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
router.post('/agiso/trade', async (req, res) => {
try {
const data = await processAgisoTradeWebhook({
headers: req.headers,
query: req.query,
body: req.body,
})
res.json(buildSuccessPayload(data, 'success'))
} catch (error) {
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
@@ -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,
}
}
+924
View File
@@ -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)}`
}
+16
View File
@@ -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
}
+213
View File
@@ -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')
)
}
+500
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
+171
View File
@@ -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
}
}
+510
View File
@@ -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
}
+326
View File
@@ -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 {}
}
}
+161
View File
@@ -0,0 +1,161 @@
export function buildSuccessPayload(data, msg = 'ok') {
return {
code: 0,
msg,
time: Math.floor(Date.now() / 1000),
data: normalizeResponseDateTime(data),
}
}
export function buildErrorPayload(error, fallbackMessage) {
const classification = classifyRouteError(error)
return {
code: 1,
msg: error instanceof Error ? error.message : fallbackMessage,
errorCode: classification.errorCode,
time: Math.floor(Date.now() / 1000),
data: null,
}
}
export function sendRouteError(res, error, fallbackMessage, scope) {
const classification = classifyRouteError(error)
const logger = classification.statusCode >= 500 ? console.error : console.warn
logger(`${scope} ${fallbackMessage}:`, error)
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
}
export function buildNotFoundPayload(req) {
return {
code: 1,
msg: `未实现接口: ${req.method} ${req.originalUrl}`,
time: Math.floor(Date.now() / 1000),
data: null,
}
}
export function createHttpError(message, { statusCode = 500, errorCode = '' } = {}) {
const error = new Error(message)
error.statusCode = statusCode
error.errorCode = errorCode || defaultErrorCodeForStatus(statusCode)
return error
}
function classifyRouteError(error) {
if (error && typeof error === 'object') {
const statusCode = Number(error.statusCode)
const errorCode = String(error.errorCode || '').trim()
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600) {
return {
statusCode,
errorCode: errorCode || defaultErrorCodeForStatus(statusCode),
}
}
}
const message = error instanceof Error ? error.message : String(error || '')
if (message.includes('缺少') || message.includes('无效') || message.includes('验签失败')) {
return { statusCode: 400, errorCode: 'invalid_request' }
}
if (message.includes('不存在')) {
return { statusCode: 404, errorCode: 'not_found' }
}
if (message.includes('已关闭')) {
return { statusCode: 410, errorCode: 'gone' }
}
if (message.includes('不能') || message.includes('冲突')) {
return { statusCode: 409, errorCode: 'conflict' }
}
if (
message.includes('OCR') ||
message.includes('uv sync') ||
message.includes('spawn uv ENOENT') ||
message.includes("Executable doesn't exist") ||
message.includes('please run the following command to download new browsers')
) {
return { statusCode: 503, errorCode: 'dependency_unavailable' }
}
return { statusCode: 500, errorCode: 'internal_error' }
}
function defaultErrorCodeForStatus(statusCode) {
if (statusCode === 400) {
return 'invalid_request'
}
if (statusCode === 401) {
return 'unauthorized'
}
if (statusCode === 403) {
return 'forbidden'
}
if (statusCode === 404) {
return 'not_found'
}
if (statusCode === 409) {
return 'conflict'
}
if (statusCode === 410) {
return 'gone'
}
if (statusCode === 503) {
return 'dependency_unavailable'
}
return 'internal_error'
}
function normalizeResponseDateTime(value) {
if (Array.isArray(value)) {
return value.map((item) => normalizeResponseDateTime(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [key, normalizeResponseDateTime(currentValue)]),
)
}
if (isIsoDateTimeString(value)) {
return formatResponseDateTime(value)
}
return value
}
function isIsoDateTimeString(value) {
return (
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value.trim())
)
}
function formatResponseDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value || '').replace('T', ' ').replace('Z', '')
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
+9
View File
@@ -0,0 +1,9 @@
import crypto from 'node:crypto'
export function randomToken(bytes = 24) {
return crypto.randomBytes(bytes).toString('hex')
}
export function randomId(prefix, bytes = 6) {
return `${prefix}${crypto.randomBytes(bytes).toString('hex')}`
}
+8
View File
@@ -0,0 +1,8 @@
export function nowIso() {
return new Date().toISOString()
}
export function addHours(baseIso, hours) {
const baseTime = baseIso ? new Date(baseIso).getTime() : Date.now()
return new Date(baseTime + hours * 60 * 60 * 1000).toISOString()
}