init
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
import { getDb } from '../db/client.js'
|
||||
import { createTaskClaimToken } from './claim-service.js'
|
||||
import { getClaimTokenById, updateClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../repositories/cdk-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../repositories/order-item-repo.js'
|
||||
import { getOrderById, listOrders } from '../repositories/order-repo.js'
|
||||
import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
|
||||
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../repositories/webhook-event-repo.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
import { reserveCdkForTask } from './cdk-service.js'
|
||||
import { replayAgisoTradeWebhookEvent } from './webhook-service.js'
|
||||
|
||||
export function getAdminDashboardSummary() {
|
||||
const db = getDb()
|
||||
const todayPrefix = nowIso().slice(0, 10)
|
||||
const summary = db.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orders WHERE substr(created_at, 1, 10) = ?) AS today_orders,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('paid', 'cdk_reserved', 'link_generated')) AS paid_pending_claim,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')) AS claiming_tasks,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status = 'redeemed' AND substr(updated_at, 1, 10) = ?) AS redeemed_today,
|
||||
(SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')) AS abnormal_tasks,
|
||||
(SELECT COUNT(DISTINCT sku_code) FROM cdk_inventory WHERE status = 'available') AS sku_with_inventory
|
||||
`).get(todayPrefix, todayPrefix)
|
||||
|
||||
return {
|
||||
todayOrders: Number(summary?.today_orders || 0),
|
||||
paidPendingClaim: Number(summary?.paid_pending_claim || 0),
|
||||
claimingTasks: Number(summary?.claiming_tasks || 0),
|
||||
redeemedToday: Number(summary?.redeemed_today || 0),
|
||||
abnormalTasks: Number(summary?.abnormal_tasks || 0),
|
||||
skuWithInventory: Number(summary?.sku_with_inventory || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminOrders(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listOrders({
|
||||
page,
|
||||
pageSize,
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
payStatus: String(query.payStatus || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminOrderListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminOrderDetail(orderId) {
|
||||
const order = getOrderById(Number(orderId))
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const items = listOrderItemsByOrderId(order.id)
|
||||
const tasks = listTasksByOrderId(order.id)
|
||||
const webhookEvents = listWebhookEventsByOrderId(order.id)
|
||||
|
||||
return {
|
||||
order: {
|
||||
orderId: order.id,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderStatus: order.order_status,
|
||||
payStatus: order.pay_status,
|
||||
buyerId: order.buyer_id,
|
||||
buyerName: order.buyer_name,
|
||||
receiverContact: order.receiver_contact,
|
||||
totalAmount: order.total_amount,
|
||||
currency: order.currency,
|
||||
paidAt: order.paid_at,
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
rawPayload: safeParseJson(order.raw_payload_json),
|
||||
bindingSummary: buildOrderBindingSummary(tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
quantity: item.quantity,
|
||||
deliveryMode: item.delivery_mode,
|
||||
spec: safeParseJson(item.spec_json),
|
||||
})),
|
||||
tasks: tasks.map(mapAdminTaskSummary),
|
||||
webhookEvents: webhookEvents.map((event) => ({
|
||||
eventId: event.id,
|
||||
platform: event.platform,
|
||||
eventType: event.event_type,
|
||||
eventKey: event.event_key,
|
||||
signatureValid: Boolean(event.signature_valid),
|
||||
processed: Boolean(event.processed),
|
||||
processError: event.process_error,
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTasks(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listTasks({
|
||||
page,
|
||||
pageSize,
|
||||
status: String(query.status || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
taskNo: String(query.taskNo || '').trim(),
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
roleId: String(query.roleId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminTaskListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTaskDetail(taskId) {
|
||||
const task = getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const order = getOrderById(task.order_id)
|
||||
const orderItem = order ? listOrderItemsByOrderId(order.id).find((item) => item.id === task.order_item_id) || null : null
|
||||
const claimToken = task.claim_token_id ? getClaimTokenById(task.claim_token_id) : null
|
||||
const cdk = task.reserved_cdk_id ? getCdkById(task.reserved_cdk_id) : null
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
...task,
|
||||
sku_code: orderItem?.sku_code || '',
|
||||
sku_name: orderItem?.sku_name || '',
|
||||
cdk_code: cdk?.cdk_code || '',
|
||||
claim_token: claimToken?.token || '',
|
||||
}),
|
||||
order: order
|
||||
? {
|
||||
orderId: order.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
}
|
||||
: null,
|
||||
orderItem: orderItem
|
||||
? {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
}
|
||||
: null,
|
||||
claimToken: claimToken
|
||||
? {
|
||||
claimTokenId: claimToken.id,
|
||||
token: claimToken.token,
|
||||
status: claimToken.status,
|
||||
expiredAt: claimToken.expired_at,
|
||||
}
|
||||
: null,
|
||||
cdk: cdk
|
||||
? {
|
||||
cdkId: cdk.id,
|
||||
skuCode: cdk.sku_code,
|
||||
batchNo: cdk.batch_no,
|
||||
cdkCode: cdk.cdk_code,
|
||||
status: cdk.status,
|
||||
}
|
||||
: null,
|
||||
artifacts: safeParseJson(task.artifacts_json),
|
||||
screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '',
|
||||
operations: {
|
||||
canRetry: ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canReleaseCdk: Boolean(task.reserved_cdk_id && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)),
|
||||
canRegenerateClaimLink: ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status),
|
||||
canClose: !['redeemed', 'closed'].includes(task.task_status),
|
||||
canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminTaskScreenshotPath(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (!task.screenshot_path) {
|
||||
throw createHttpError('当前任务还没有截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
export function getAdminCdks(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listCdks({
|
||||
page,
|
||||
pageSize,
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
batchNo: String(query.batchNo || '').trim(),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminCdkListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function createAdminCdk(payload = {}) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const cdkCode = String(payload.cdkCode || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
|
||||
if (!skuCode || !cdkCode) {
|
||||
throw createHttpError('缺少 skuCode 或 cdkCode', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_create_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const created = createCdks([
|
||||
{
|
||||
skuCode,
|
||||
cdkCode,
|
||||
batchNo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
|
||||
if (created === 0) {
|
||||
throw createHttpError('CDK 已存在,不能重复新增', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_duplicate',
|
||||
})
|
||||
}
|
||||
|
||||
const { items } = listCdks({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
skuCode,
|
||||
})
|
||||
const createdItem = items.find((item) => item.cdk_code === cdkCode) || null
|
||||
|
||||
return {
|
||||
cdk: createdItem ? mapAdminCdkListItem(createdItem) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function importAdminCdks(payload = {}) {
|
||||
const rows = normalizeCdkImportRows(payload)
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw createHttpError('没有可导入的 CDK 数据', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_import_empty',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const normalizedRows = rows.map((row) => ({
|
||||
skuCode: row.skuCode,
|
||||
batchNo: row.batchNo,
|
||||
cdkCode: row.cdkCode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
|
||||
const created = createCdks(normalizedRows)
|
||||
|
||||
return {
|
||||
total: normalizedRows.length,
|
||||
created,
|
||||
duplicated: normalizedRows.length - created,
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseAdminInventoryCdk(cdkId) {
|
||||
const cdk = getRequiredCdk(cdkId)
|
||||
|
||||
if (cdk.status !== 'reserved') {
|
||||
throw createHttpError('当前 CDK 不是预占状态,不能释放', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const updated = releaseReservedCdk(cdk.id, nowIso())
|
||||
|
||||
return {
|
||||
cdk: mapAdminCdkListItem(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateAdminCdk(cdkId, payload = {}) {
|
||||
const cdk = getRequiredCdk(cdkId)
|
||||
|
||||
if (cdk.status !== 'available') {
|
||||
throw createHttpError('只有可用 CDK 才能作废,请先释放预占', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_cdk_invalidate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const reason = String(payload.reason || '').trim() || '后台手动作废'
|
||||
const updated = invalidateCdk(cdk.id, reason, nowIso())
|
||||
|
||||
return {
|
||||
cdk: mapAdminCdkListItem(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminWebhookEvents(query = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = listWebhookEvents({
|
||||
page,
|
||||
pageSize,
|
||||
platform: String(query.platform || '').trim(),
|
||||
processed: String(query.processed || '').trim(),
|
||||
relatedOrderId: String(query.relatedOrderId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
eventId: item.id,
|
||||
platform: item.platform,
|
||||
eventType: item.event_type,
|
||||
eventKey: item.event_key,
|
||||
signatureValid: Boolean(item.signature_valid),
|
||||
processed: Boolean(item.processed),
|
||||
processError: item.process_error,
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
})),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export function getAdminWebhookEventDetail(eventId) {
|
||||
const event = getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
platform: event.platform,
|
||||
eventType: event.event_type,
|
||||
eventKey: event.event_key,
|
||||
signatureValid: Boolean(event.signature_valid),
|
||||
processed: Boolean(event.processed),
|
||||
processError: event.process_error,
|
||||
relatedOrderId: event.related_order_id,
|
||||
createdAt: event.created_at,
|
||||
headers: safeParseJson(event.headers_json),
|
||||
query: safeParseJson(event.query_json),
|
||||
body: safeParseJson(event.body_json),
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayAdminWebhookEvent(eventId) {
|
||||
const event = getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (event.platform !== 'agiso') {
|
||||
throw createHttpError('当前只支持重放 agiso webhook', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_webhook_replay_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await replayAgisoTradeWebhookEvent(event)
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
replayed: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
export function retryAdminTask(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
|
||||
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const orderItem = listOrderItemsByOrderId(task.order_id).find((item) => item.id === task.order_item_id) || null
|
||||
let reservedCdkId = task.reserved_cdk_id
|
||||
let claimTokenId = task.claim_token_id
|
||||
let nextStatus = 'link_generated'
|
||||
let lastError = ''
|
||||
let expiresAt = task.expires_at
|
||||
let claimUrl = ''
|
||||
let token = ''
|
||||
|
||||
if (!reservedCdkId) {
|
||||
const reserved = reserveCdkForTask(orderItem?.sku_code || '', task.id)
|
||||
|
||||
if (!reserved) {
|
||||
nextStatus = 'waiting_inventory'
|
||||
lastError = '库存不足,等待可用 CDK'
|
||||
} else {
|
||||
reservedCdkId = reserved.id
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === 'link_generated' && !claimTokenId) {
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
claimTokenId = claimToken.id
|
||||
expiresAt = claimToken.expired_at
|
||||
claimUrl = claimToken.claimUrl
|
||||
token = claimToken.token
|
||||
}
|
||||
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: nextStatus,
|
||||
reserved_cdk_id: reservedCdkId,
|
||||
claim_token_id: claimTokenId,
|
||||
expires_at: expiresAt,
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const payload = {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
|
||||
if (claimUrl) {
|
||||
payload.claimUrl = claimUrl
|
||||
payload.token = token
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function releaseAdminTaskCdk(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (!task.reserved_cdk_id) {
|
||||
throw createHttpError('当前任务没有预占 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_no_reserved_cdk',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
releaseReservedCdk(task.reserved_cdk_id, nowIso())
|
||||
const updatedTask = updateTask(task.id, {
|
||||
reserved_cdk_id: null,
|
||||
task_status: 'waiting_inventory',
|
||||
last_error: '已手动释放预占 CDK',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export function regenerateAdminTaskClaimLink(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重新生成领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_regenerate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.claim_token_id) {
|
||||
updateClaimToken(task.claim_token_id, {
|
||||
status: 'revoked',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
const updatedTask = updateTask(task.id, {
|
||||
claim_token_id: claimToken.id,
|
||||
expires_at: claimToken.expired_at,
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimToken.claimUrl,
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAdminTask(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_close_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: 'closed',
|
||||
last_error: task.last_error || '已手动关闭任务',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export function markAdminTaskManualReview(taskId) {
|
||||
const task = getRequiredTask(taskId)
|
||||
const updatedTask = updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: task.last_error || '已转人工处理',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminTaskSummary(task) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
lastError: task.last_error,
|
||||
retryCount: task.retry_count,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminCdkListItem(item) {
|
||||
const task = item.reserved_by_task_id ? getTaskById(item.reserved_by_task_id) : null
|
||||
const order = task?.order_id ? getOrderById(task.order_id) : null
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
cdkId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
batchNo: item.batch_no,
|
||||
cdkCode: item.cdk_code,
|
||||
status: item.status,
|
||||
reservedByTaskId: item.reserved_by_task_id,
|
||||
reservedByTaskNo: task?.task_no || '',
|
||||
platformOrderId: order?.platform_order_id || '',
|
||||
systemBindingStatus: item.status === 'delivered' ? 'system_bound' : binding.systemBindingStatus,
|
||||
userBindingStatus: item.status === 'delivered' ? 'binding_completed' : binding.userBindingStatus,
|
||||
invalidReason: item.invalid_reason || '',
|
||||
deliveredAt: item.delivered_at,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapTaskActionPayload(task) {
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
reservedCdkId: task.reserved_cdk_id,
|
||||
claimTokenId: task.claim_token_id,
|
||||
lastError: task.last_error,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredTask(taskId) {
|
||||
const task = getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
function getRequiredCdk(cdkId) {
|
||||
const cdk = getCdkById(Number(cdkId))
|
||||
|
||||
if (!cdk) {
|
||||
throw createHttpError('CDK 不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_cdk_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return cdk
|
||||
}
|
||||
|
||||
function mapAdminTaskListItem(task) {
|
||||
const binding = buildTaskBindingState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
platformOrderId: task.platform_order_id,
|
||||
skuCode: task.sku_code || '',
|
||||
skuName: task.sku_name || '',
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
loginType: task.login_type,
|
||||
roleName: task.role_name,
|
||||
roleId: task.role_id,
|
||||
browserSessionId: task.browser_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: task.retry_count,
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
reservedCdkCodeMasked: maskCode(task.cdk_code),
|
||||
claimToken: task.claim_token || '',
|
||||
screenshotPath: task.screenshot_path || '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminOrderListItem(item) {
|
||||
const tasks = listTasksByOrderId(item.id)
|
||||
const bindingSummary = buildOrderBindingSummary(tasks)
|
||||
|
||||
return {
|
||||
orderId: item.id,
|
||||
platform: item.platform,
|
||||
platformOrderId: item.platform_order_id,
|
||||
orderStatus: item.order_status,
|
||||
payStatus: item.pay_status,
|
||||
buyerName: item.buyer_name,
|
||||
totalAmount: item.total_amount,
|
||||
currency: item.currency,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
taskCount: Number(item.task_count || tasks.length || 0),
|
||||
systemBindingStatus: bindingSummary.systemBindingStatus,
|
||||
userBindingStatus: bindingSummary.userBindingStatus,
|
||||
systemBoundTaskCount: bindingSummary.systemBoundTaskCount,
|
||||
completedBindingTaskCount: bindingSummary.completedBindingTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function buildOrderBindingSummary(tasks) {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
||||
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
||||
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
||||
|
||||
let systemBindingStatus = 'pending_binding'
|
||||
let userBindingStatus = 'not_started'
|
||||
|
||||
if (totalTaskCount === 0) {
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
userBindingStatus = 'binding_completed'
|
||||
} else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'user_binding'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'waiting_user_claim'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'binding_exception'
|
||||
} else if (systemBoundTaskCount > 0) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
||||
systemBindingStatus = 'binding_exception'
|
||||
}
|
||||
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskBindingState(task) {
|
||||
const normalizedStatus = String(task?.task_status || '').trim()
|
||||
|
||||
if (!normalizedStatus) {
|
||||
return {
|
||||
systemBindingStatus: 'pending_binding',
|
||||
userBindingStatus: 'not_started',
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'pending_payment') {
|
||||
return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'paid') {
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'waiting_inventory') {
|
||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'manual_review') {
|
||||
return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'retry_pending') {
|
||||
return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'closed') {
|
||||
return { systemBindingStatus: 'closed', userBindingStatus: 'closed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'expired') {
|
||||
return { systemBindingStatus: 'expired', userBindingStatus: 'expired' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'link_generated') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'claimed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'role_confirmed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeeming') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeemed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
}
|
||||
|
||||
if (isTaskSystemBound(task)) {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
}
|
||||
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
}
|
||||
|
||||
function isTaskSystemBound(task) {
|
||||
return Boolean(task && (task.reserved_cdk_id || task.claim_token_id))
|
||||
}
|
||||
|
||||
function normalizeCdkImportRows(payload) {
|
||||
const rows = []
|
||||
const directRows = Array.isArray(payload.rows) ? payload.rows : []
|
||||
const bulkCodes = Array.isArray(payload.codes) ? payload.codes : []
|
||||
|
||||
if (directRows.length > 0) {
|
||||
for (const row of directRows) {
|
||||
const skuCode = String(row?.skuCode || '').trim()
|
||||
const cdkCode = String(row?.cdkCode || '').trim()
|
||||
const batchNo = String(row?.batchNo || '').trim()
|
||||
|
||||
if (!skuCode || !cdkCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
rows.push({ skuCode, cdkCode, batchNo })
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkCodes.length > 0) {
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const batchNo = String(payload.batchNo || '').trim()
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError('批量导入时缺少 skuCode', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_cdk_import_missing_sku',
|
||||
})
|
||||
}
|
||||
|
||||
for (const code of bulkCodes) {
|
||||
const cdkCode = String(code || '').trim()
|
||||
if (!cdkCode) {
|
||||
continue
|
||||
}
|
||||
rows.push({ skuCode, cdkCode, batchNo })
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeRows(rows)
|
||||
}
|
||||
|
||||
function dedupeRows(rows) {
|
||||
const seen = new Set()
|
||||
const output = []
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.skuCode}::${row.cdkCode}`
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
output.push(row)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizePage(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1
|
||||
}
|
||||
|
||||
function normalizePageSize(rawValue) {
|
||||
const parsed = Number(rawValue)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 20
|
||||
}
|
||||
return Math.min(100, Math.floor(parsed))
|
||||
}
|
||||
|
||||
function normalizeDateQuery(rawValue, endOfDay = false) {
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function safeParseJson(rawText) {
|
||||
try {
|
||||
return JSON.parse(String(rawText || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function maskCode(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (text.length <= 8) {
|
||||
return `${text.slice(0, 2)}****${text.slice(-2)}`
|
||||
}
|
||||
return `${text.slice(0, 4)}****${text.slice(-4)}`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { assignReservedCdk, findFirstAvailableCdkBySkuCode } from '../repositories/cdk-repo.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export function reserveCdkForTask(skuCode, taskId) {
|
||||
if (!skuCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const available = findFirstAvailableCdkBySkuCode(skuCode)
|
||||
|
||||
if (!available) {
|
||||
return null
|
||||
}
|
||||
|
||||
return assignReservedCdk(available.id, taskId, nowIso())
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import { createClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { addHours, nowIso } from '../utils/time.js'
|
||||
import { randomToken } from '../utils/random.js'
|
||||
|
||||
export function createTaskClaimToken(taskId) {
|
||||
const createdAt = nowIso()
|
||||
const expiredAt = addHours(createdAt, Number(runtimeConfig.orders.tokenTtlHours || 24))
|
||||
const token = createClaimToken({
|
||||
taskId,
|
||||
token: randomToken(24),
|
||||
status: 'active',
|
||||
expiredAt,
|
||||
usedAt: null,
|
||||
maxUseCount: 1,
|
||||
usedCount: 0,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
return {
|
||||
...token,
|
||||
claimUrl: buildClaimUrl(token.token),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaimUrl(token) {
|
||||
const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim()
|
||||
return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import {
|
||||
createTencentBrowserSession,
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
redeemTencentBrowserSession,
|
||||
} from './session.js'
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../repositories/claim-token-repo.js'
|
||||
import { getOrderById } from '../repositories/order-repo.js'
|
||||
import { getOrderItemById } from '../repositories/order-item-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js'
|
||||
import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
const session = await loadTaskSession(context.task, { includeQrImage })
|
||||
const syncedTask = session ? syncTaskWithSession(context.task, session) : context.task
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createClaimSession(token, payload = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
if (context.task.browser_session_id) {
|
||||
const existingSession = await getTencentBrowserSession(context.task.browser_session_id)
|
||||
const syncedTask = syncTaskWithSession(context.task, existingSession)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: existingSession,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await createTencentBrowserSession({
|
||||
loginType: payload.loginType,
|
||||
})
|
||||
const updatedTask = updateTask(context.task.id, {
|
||||
task_status: 'claimed',
|
||||
browser_session_id: session.sessionId,
|
||||
login_type: session.loginType,
|
||||
claimed_at: context.task.claimed_at || nowIso(),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClaimSessionSummary(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_session_not_created',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await getTencentBrowserSession(context.task.browser_session_id)
|
||||
const syncedTask = syncTaskWithSession(context.task, session)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmClaimRole(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_session_not_created',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await getTencentBrowserSession(context.task.browser_session_id)
|
||||
const activityInfo = session.activityInfo || null
|
||||
|
||||
if (!activityInfo?.role?.ready) {
|
||||
throw createHttpError('当前角色信息还没有准备好', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_role_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTask = updateTask(context.task.id, {
|
||||
task_status: 'role_confirmed',
|
||||
nickname: String(activityInfo.nickname || ''),
|
||||
role_id: String(activityInfo.role.roleId || ''),
|
||||
role_name: String(activityInfo.role.roleName || ''),
|
||||
area: String(activityInfo.role.area || ''),
|
||||
partition_name: String(activityInfo.role.partition || ''),
|
||||
role_confirmed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function redeemClaimTask(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_session_not_created',
|
||||
})
|
||||
}
|
||||
|
||||
if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') {
|
||||
throw createHttpError('当前任务还未确认角色,不能开始兑换', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_role_not_confirmed',
|
||||
})
|
||||
}
|
||||
|
||||
const cdk = context.task.reserved_cdk_id ? getCdkById(context.task.reserved_cdk_id) : null
|
||||
|
||||
if (!cdk || !String(cdk.cdk_code || '').trim()) {
|
||||
throw createHttpError('当前任务没有可用的预占 CDK', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_cdk_not_reserved',
|
||||
})
|
||||
}
|
||||
|
||||
updateTask(context.task.id, {
|
||||
task_status: 'redeeming',
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
|
||||
code: cdk.cdk_code,
|
||||
})
|
||||
const finalRedeem = session.redeem?.final?.redeem || null
|
||||
const updatedTask = updateTask(context.task.id, {
|
||||
task_status: 'redeemed',
|
||||
result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'),
|
||||
result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'),
|
||||
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
||||
artifacts_json: JSON.stringify(session.artifacts || {}),
|
||||
redeemed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
markCdkDelivered(cdk.id, nowIso())
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
} catch (error) {
|
||||
const nextRetryCount = Number(context.task.retry_count || 0) + 1
|
||||
const failedTask = updateTask(context.task.id, {
|
||||
task_status: 'retry_pending',
|
||||
retry_count: nextRetryCount,
|
||||
last_error: error instanceof Error ? error.message : String(error || ''),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
|
||||
task: failedTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaimScreenshotPath(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
if (context.task.screenshot_path) {
|
||||
return context.task.screenshot_path
|
||||
}
|
||||
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有兑换截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_screenshot_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id)
|
||||
}
|
||||
|
||||
async function getClaimContext(token) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createHttpError('缺少领取 token', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_claim_token',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = findClaimTokenByToken(normalized)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接无效或不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const task = findTaskByClaimTokenId(claimToken.id)
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.status !== 'active') {
|
||||
throw createHttpError('领取链接当前不可用', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_inactive',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
|
||||
const expiredContext = expireClaimContext(claimToken, task)
|
||||
|
||||
throw createHttpError('领取链接已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_expired',
|
||||
context: expiredContext,
|
||||
})
|
||||
}
|
||||
|
||||
const order = getOrderById(task.order_id)
|
||||
const orderItem = getOrderItemById(task.order_item_id)
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
function assertTaskCanProceed(task) {
|
||||
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
|
||||
throw createHttpError('当前任务已经结束,不能继续操作', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_task_closed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = updateClaimToken(claimToken.id, {
|
||||
status: 'expired',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
let nextTask = task
|
||||
|
||||
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
||||
if (task.reserved_cdk_id) {
|
||||
releaseReservedCdk(task.reserved_cdk_id, now)
|
||||
}
|
||||
|
||||
nextTask = updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
reserved_cdk_id: null,
|
||||
last_error: '领取链接已过期,预占 CDK 已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken: nextClaimToken,
|
||||
task: nextTask,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskSession(task, { includeQrImage = false } = {}) {
|
||||
if (!task.browser_session_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return includeQrImage
|
||||
? getTencentBrowserSession(task.browser_session_id)
|
||||
: getTencentBrowserSessionSummary(task.browser_session_id)
|
||||
}
|
||||
|
||||
function syncTaskWithSession(task, session) {
|
||||
const activityInfo = session.activityInfo || null
|
||||
const patch = {
|
||||
login_type: String(session.loginType || task.login_type || ''),
|
||||
updated_at: nowIso(),
|
||||
}
|
||||
|
||||
if (activityInfo?.nickname) {
|
||||
patch.nickname = String(activityInfo.nickname)
|
||||
}
|
||||
|
||||
if (activityInfo?.role?.ready) {
|
||||
patch.role_id = String(activityInfo.role.roleId || '')
|
||||
patch.role_name = String(activityInfo.role.roleName || '')
|
||||
patch.area = String(activityInfo.role.area || '')
|
||||
patch.partition_name = String(activityInfo.role.partition || '')
|
||||
}
|
||||
|
||||
if (task.task_status === 'link_generated') {
|
||||
patch.task_status = 'claimed'
|
||||
patch.claimed_at = task.claimed_at || nowIso()
|
||||
}
|
||||
|
||||
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
|
||||
patch.screenshot_path = task.screenshot_path || ''
|
||||
}
|
||||
|
||||
return updateTask(task.id, patch)
|
||||
}
|
||||
|
||||
function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) {
|
||||
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
|
||||
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
|
||||
const finalRedeem = session?.redeem?.final?.redeem || null
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
expiresAt: task.expires_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
loginType: task.login_type,
|
||||
lastError: task.last_error,
|
||||
browserSessionId: task.browser_session_id,
|
||||
},
|
||||
order: {
|
||||
orderId: order.id,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
totalAmount: order.total_amount,
|
||||
currency: order.currency,
|
||||
},
|
||||
orderItem: {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
session,
|
||||
result: task.redeemed_at || session?.status === 'redeemed'
|
||||
? {
|
||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
||||
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
|
||||
screenshotReady,
|
||||
screenshotUrl,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createTask, listTasksByOrderId, updateTask } from '../repositories/task-repo.js'
|
||||
import { reserveCdkForTask } from './cdk-service.js'
|
||||
import { createTaskClaimToken } from './claim-service.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
import { randomId } from '../utils/random.js'
|
||||
|
||||
export function syncDeliveryTasksForOrder(order, orderItems) {
|
||||
const existingTasks = listTasksByOrderId(order.id)
|
||||
|
||||
if (existingTasks.length > 0) {
|
||||
if (order.pay_status !== 'paid') {
|
||||
return existingTasks
|
||||
}
|
||||
|
||||
const itemMap = new Map(orderItems.map((item) => [item.id, item]))
|
||||
return existingTasks.map((task) => preparePaidTask({
|
||||
...task,
|
||||
skuCode: itemMap.get(task.order_item_id)?.sku_code || '',
|
||||
skuName: itemMap.get(task.order_item_id)?.sku_name || '',
|
||||
}))
|
||||
}
|
||||
|
||||
const tasks = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
|
||||
for (let index = 0; index < quantity; index += 1) {
|
||||
const createdAt = nowIso()
|
||||
const initialStatus = order.pay_status === 'paid' ? 'paid' : 'pending_payment'
|
||||
|
||||
const task = createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
taskNo: randomId('DT'),
|
||||
taskStatus: initialStatus,
|
||||
loginType: '',
|
||||
claimTokenId: null,
|
||||
reservedCdkId: null,
|
||||
browserSessionId: '',
|
||||
nickname: '',
|
||||
roleId: '',
|
||||
roleName: '',
|
||||
area: '',
|
||||
partitionName: '',
|
||||
resultCode: '',
|
||||
resultMessage: '',
|
||||
screenshotPath: '',
|
||||
artifactsJson: '{}',
|
||||
lastError: '',
|
||||
retryCount: 0,
|
||||
expiresAt: null,
|
||||
claimedAt: null,
|
||||
roleConfirmedAt: null,
|
||||
redeemedAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
tasks.push({
|
||||
...task,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (order.pay_status !== 'paid') {
|
||||
return tasks
|
||||
}
|
||||
|
||||
return tasks.map((task) => preparePaidTask(task))
|
||||
}
|
||||
|
||||
function preparePaidTask(task) {
|
||||
const now = nowIso()
|
||||
|
||||
if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) {
|
||||
return task
|
||||
}
|
||||
|
||||
if (!task.skuCode) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
last_error: '未匹配到 SKU,无法为任务分配 CDK',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.reserved_cdk_id && task.claim_token_id) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const reserved = reserveCdkForTask(task.skuCode, task.id)
|
||||
|
||||
if (!reserved) {
|
||||
return updateTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
last_error: '库存不足,等待可用 CDK',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = createTaskClaimToken(task.id)
|
||||
|
||||
return updateTask(task.id, {
|
||||
task_status: 'link_generated',
|
||||
reserved_cdk_id: reserved.id,
|
||||
claim_token_id: claimToken.id,
|
||||
last_error: '',
|
||||
expires_at: claimToken.expired_at,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import crypto from 'node:crypto'
|
||||
import {
|
||||
createMessageDelivery,
|
||||
findLatestSuccessfulMessageDeliveryByTask,
|
||||
updateMessageDelivery,
|
||||
} from '../repositories/message-delivery-repo.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
const AGISO_MESSAGE_CHANNEL = 'agiso_im'
|
||||
|
||||
export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) {
|
||||
if (!order || !task || !claimUrl) {
|
||||
return { sent: false, skipped: true, reason: 'missing_message_context' }
|
||||
}
|
||||
|
||||
const config = runtimeConfig.platforms?.agiso?.messaging || {}
|
||||
if (!config.enabled) {
|
||||
return { sent: false, skipped: true, reason: 'messaging_disabled' }
|
||||
}
|
||||
|
||||
const endpoint = String(config.sendMessageEndpoint || '').trim()
|
||||
const accessToken = String(config.accessToken || '').trim()
|
||||
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
|
||||
if (!endpoint || !accessToken || !appSecret) {
|
||||
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
|
||||
}
|
||||
|
||||
const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_MESSAGE_CHANNEL)
|
||||
if (successful) {
|
||||
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
|
||||
}
|
||||
|
||||
const messageContent = renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template: config.messageTemplate })
|
||||
const url = buildRequestUrl(endpoint)
|
||||
const requestBody = buildRequestBody({
|
||||
tid: String(order.platform_order_id || ''),
|
||||
msg: messageContent,
|
||||
appSecret,
|
||||
})
|
||||
const requestHeaders = buildRequestHeaders({
|
||||
accessToken,
|
||||
apiVersion: String(config.apiVersion || '1').trim() || '1',
|
||||
})
|
||||
const createdAt = nowIso()
|
||||
const delivery = createMessageDelivery({
|
||||
platform: 'agiso',
|
||||
channel: AGISO_MESSAGE_CHANNEL,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
recipientKey: order.platform_order_id,
|
||||
messageContent,
|
||||
claimUrl,
|
||||
status: 'pending',
|
||||
requestUrl: url,
|
||||
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
|
||||
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
|
||||
responseStatus: 0,
|
||||
responseJson: '{}',
|
||||
errorMessage: '',
|
||||
sentAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
})
|
||||
const rawText = await response.text()
|
||||
const parsed = safeParseJson(rawText)
|
||||
const success = isAgisoSendSuccess(response.status, parsed)
|
||||
const updated = updateMessageDelivery(delivery.id, {
|
||||
status: success ? 'success' : 'failed',
|
||||
response_status: response.status,
|
||||
response_json: JSON.stringify(parsed ?? { rawText }),
|
||||
error_message: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
|
||||
sent_at: success ? nowIso() : null,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: success,
|
||||
skipped: false,
|
||||
deliveryId: updated?.id || delivery.id,
|
||||
responseStatus: response.status,
|
||||
response: parsed ?? { rawText },
|
||||
errorMessage: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status),
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
|
||||
updateMessageDelivery(delivery.id, {
|
||||
status: 'failed',
|
||||
response_status: 0,
|
||||
response_json: '{}',
|
||||
error_message: message,
|
||||
sent_at: null,
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: false,
|
||||
skipped: false,
|
||||
deliveryId: delivery.id,
|
||||
errorMessage: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestUrl(endpoint) {
|
||||
return new URL(endpoint).toString()
|
||||
}
|
||||
|
||||
function buildRequestHeaders({ accessToken, apiVersion }) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: apiVersion,
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({ tid, msg, appSecret }) {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const payload = {
|
||||
tid,
|
||||
msg,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params, appSecret) {
|
||||
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
|
||||
let raw = appSecret
|
||||
|
||||
for (const [key, value] of sortedEntries) {
|
||||
raw += `${key}${value}`
|
||||
}
|
||||
|
||||
raw += appSecret
|
||||
|
||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
function renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template }) {
|
||||
const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
|
||||
|
||||
return source
|
||||
.replaceAll('{platformOrderId}', String(order.platform_order_id || ''))
|
||||
.replaceAll('{taskNo}', String(task.task_no || ''))
|
||||
.replaceAll('{claimUrl}', claimUrl)
|
||||
.replaceAll('{expiredAt}', String(expiredAt || '尽快'))
|
||||
}
|
||||
|
||||
function isAgisoSendSuccess(statusCode, payload) {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (payload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveAgisoErrorMessage(payload, rawText, statusCode) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
return text || `Agiso 发消息失败,HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
function safeParseJson(rawText) {
|
||||
const normalized = String(rawText || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function maskHeadersForStorage(headers) {
|
||||
const output = { ...headers }
|
||||
|
||||
if (output.Authorization) {
|
||||
output.Authorization = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function maskBodyForStorage(body) {
|
||||
const output = { ...body }
|
||||
|
||||
if (output.sign) {
|
||||
output.sign = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import readline from 'node:readline'
|
||||
import process from 'node:process'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
let workerPromise = null
|
||||
let requestSequence = 0
|
||||
const pendingRequests = new Map()
|
||||
let stderrBuffer = []
|
||||
|
||||
export async function recognizeTencentCaptcha(payload) {
|
||||
return callOcrWorker('recognize', payload)
|
||||
}
|
||||
|
||||
export async function batchRecognizeTencentCaptcha(payload) {
|
||||
return callOcrWorker('batch', payload, { timeoutMs: 180_000 })
|
||||
}
|
||||
|
||||
export async function warmupLocalOcrWorker() {
|
||||
await ensureOcrWorker()
|
||||
}
|
||||
|
||||
export async function closeLocalOcrWorker() {
|
||||
const worker = await workerPromise?.catch(() => null)
|
||||
|
||||
if (!worker) {
|
||||
workerPromise = null
|
||||
return
|
||||
}
|
||||
|
||||
worker.child.kill()
|
||||
workerPromise = null
|
||||
}
|
||||
|
||||
async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const worker = await ensureOcrWorker()
|
||||
const requestId = `ocr-${Date.now()}-${++requestSequence}`
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingRequests.delete(requestId)
|
||||
worker.child.kill()
|
||||
reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常'))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
|
||||
worker.child.stdin.write(
|
||||
`${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`,
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureOcrWorker() {
|
||||
if (!workerPromise) {
|
||||
workerPromise = createOcrWorker().catch((error) => {
|
||||
workerPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return workerPromise
|
||||
}
|
||||
|
||||
function createOcrWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const projectRoot = resolveOcrProjectRoot()
|
||||
stderrBuffer = []
|
||||
|
||||
const child = spawn('uv', ['run', 'ocr-worker', 'worker'], {
|
||||
cwd: projectRoot,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
})
|
||||
child.stdin.setDefaultEncoding('utf8')
|
||||
|
||||
const stdoutReader = readline.createInterface({ input: child.stdout })
|
||||
const worker = { child, stdoutReader }
|
||||
|
||||
const readyTimer = setTimeout(() => {
|
||||
reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`))
|
||||
child.kill()
|
||||
}, 15_000)
|
||||
|
||||
let ready = false
|
||||
|
||||
stdoutReader.on('line', (line) => {
|
||||
const message = tryParseJson(line)
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
if (!ready) {
|
||||
ready = true
|
||||
clearTimeout(readyTimer)
|
||||
resolve(worker)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type !== 'response') {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = String(message.id || '')
|
||||
const pending = pendingRequests.get(requestId)
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingRequests.delete(requestId)
|
||||
pending.resolve(message.payload)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
stderrBuffer.push(text)
|
||||
if (stderrBuffer.length > 20) {
|
||||
stderrBuffer = stderrBuffer.slice(-20)
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(readyTimer)
|
||||
const workerError = buildWorkerError(error)
|
||||
failAllPendingRequests(workerError)
|
||||
if (!ready) {
|
||||
reject(workerError)
|
||||
}
|
||||
workerPromise = null
|
||||
})
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(readyTimer)
|
||||
const workerError = new Error(
|
||||
buildWorkerExitMessage({
|
||||
code,
|
||||
signal,
|
||||
projectRoot,
|
||||
stderrText: stderrBuffer.join('\n'),
|
||||
}),
|
||||
)
|
||||
failAllPendingRequests(workerError)
|
||||
if (!ready) {
|
||||
reject(workerError)
|
||||
}
|
||||
workerPromise = null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function failAllPendingRequests(error) {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
pendingRequests.delete(requestId)
|
||||
pending.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOcrProjectRoot() {
|
||||
return String(runtimeConfig.ocr.projectRoot || '').trim()
|
||||
}
|
||||
|
||||
function buildWorkerError(error) {
|
||||
if (error instanceof Error && error.message.includes('spawn uv ENOENT')) {
|
||||
return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里')
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
|
||||
)
|
||||
}
|
||||
|
||||
function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) {
|
||||
const reason = signal
|
||||
? `signal ${signal}`
|
||||
: typeof code === 'number'
|
||||
? `exit code ${code}`
|
||||
: 'unknown reason'
|
||||
|
||||
const installHint = `请先执行: cd ${projectRoot} && uv sync`
|
||||
const stderrHint = stderrText ? `\n${stderrText}` : ''
|
||||
|
||||
return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}`
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../repositories/order-repo.js'
|
||||
import { replaceOrderItems } from '../repositories/order-item-repo.js'
|
||||
import { getClaimTokenById } from '../repositories/claim-token-repo.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { ensureAgisoClaimMessageDeliveredForTask } from './message-service.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export async function upsertOrderFromWebhook(event) {
|
||||
const now = nowIso()
|
||||
const existing = findOrderByPlatformOrderId(event.platform, event.platformOrderId)
|
||||
const basePayload = {
|
||||
platform: event.platform,
|
||||
platformOrderId: event.platformOrderId,
|
||||
orderStatus: event.orderStatus,
|
||||
payStatus: event.payStatus,
|
||||
buyerId: event.buyerId,
|
||||
buyerName: event.buyerName,
|
||||
receiverContact: event.receiverContact,
|
||||
totalAmount: event.totalAmount,
|
||||
currency: event.currency,
|
||||
rawPayloadJson: JSON.stringify(event.rawPayload),
|
||||
paidAt: event.paidAt,
|
||||
}
|
||||
|
||||
const order = existing
|
||||
? updateOrder(existing.id, {
|
||||
...basePayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
: createOrder({
|
||||
...basePayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const orderItems = replaceOrderItems(
|
||||
order.id,
|
||||
event.items.map((item) => ({
|
||||
skuCode: item.skuCode,
|
||||
skuName: item.skuName,
|
||||
quantity: item.quantity,
|
||||
specJson: JSON.stringify(item.spec || {}),
|
||||
deliveryMode: 'claim_link',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
|
||||
const tasks = syncDeliveryTasksForOrder(order, orderItems)
|
||||
const messageDeliveries = []
|
||||
|
||||
for (const task of tasks) {
|
||||
if (event.platform !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) {
|
||||
continue
|
||||
}
|
||||
|
||||
const claimToken = getClaimTokenById(task.claim_token_id)
|
||||
if (!claimToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await ensureAgisoClaimMessageDeliveredForTask({
|
||||
order,
|
||||
task,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
expiredAt: claimToken.expired_at,
|
||||
})
|
||||
messageDeliveries.push({
|
||||
taskId: task.id,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
orderItems,
|
||||
tasks,
|
||||
messageDeliveries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
|
||||
export function logBrowserSessionDebug(scope, detail) {
|
||||
if (!SESSION_DEBUG_ENABLED) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof detail === 'undefined') {
|
||||
console.log(`[browser/session][debug] ${scope}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[browser/session][debug] ${scope}:`, detail)
|
||||
}
|
||||
|
||||
export async function waitForFrame(page, predicate, timeoutMs = 15_000) {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const matched = page.frames().find((frame) => {
|
||||
try {
|
||||
return predicate(frame)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
throw new Error('未找到登录二维码 frame')
|
||||
}
|
||||
|
||||
export async function downloadRemoteQrImage(page, qrUrl, qrImagePath, referer = '', loginType = 'unknown') {
|
||||
try {
|
||||
const response = await page.context().request.get(qrUrl, {
|
||||
failOnStatusCode: false,
|
||||
headers: {
|
||||
referer,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, {
|
||||
qrUrl,
|
||||
status: response.status(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const body = await response.body()
|
||||
|
||||
if (!body?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return body
|
||||
} catch (error) {
|
||||
logBrowserSessionDebug(`${loginType}.capture.downloadError`, {
|
||||
qrUrl,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isRetryableQrCaptureError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
|
||||
return (
|
||||
message.includes('Frame was detached') ||
|
||||
message.includes('Execution context was destroyed') ||
|
||||
message.includes('Target page, context or browser has been closed') ||
|
||||
message.includes('waiting for') ||
|
||||
message.includes('Timeout')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
|
||||
const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4'
|
||||
const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
|
||||
const BEIJING_TIME_PROOF_CLIP = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: BEIJING_TIME_PROOF_VIEWPORT.width,
|
||||
height: 620,
|
||||
}
|
||||
const DEFAULT_REDEEM_PROOF_MODE = 'full'
|
||||
|
||||
export async function saveRedeemArtifacts({
|
||||
browserContext,
|
||||
page,
|
||||
sessionDir,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
}) {
|
||||
return writeRedeemArtifactFiles({
|
||||
browserContext,
|
||||
page,
|
||||
outputDir: sessionDir,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
proofMode: resolveRedeemProofMode(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeRedeemArtifactFiles({
|
||||
browserContext,
|
||||
page,
|
||||
outputDir,
|
||||
sessionId = '',
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
proofMode = DEFAULT_REDEEM_PROOF_MODE,
|
||||
}) {
|
||||
const effectiveProofMode = resolveRedeemProofMode(proofMode)
|
||||
const {
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
resultPath,
|
||||
} = buildArtifactPaths(outputDir)
|
||||
|
||||
if (effectiveProofMode === 'off') {
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath: '',
|
||||
htmlPath: '',
|
||||
resultPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成')
|
||||
|
||||
if (effectiveProofMode === 'basic') {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await writeRedeemResultFile(resultPath, {
|
||||
proofMode: effectiveProofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
})
|
||||
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath,
|
||||
htmlPath: '',
|
||||
resultPath,
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true })
|
||||
|
||||
let beijingTimeProof = {
|
||||
screenshotPath: '',
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
error: '',
|
||||
}
|
||||
|
||||
try {
|
||||
beijingTimeProof = await captureBeijingTimeProof(browserContext, {
|
||||
screenshotPath: beijingTimeScreenshotPath,
|
||||
})
|
||||
await composeProofScreenshot(browserContext, {
|
||||
outputPath: screenshotPath,
|
||||
redeemImagePath: redeemPageScreenshotPath,
|
||||
timeImagePath: beijingTimeProof.screenshotPath,
|
||||
redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'),
|
||||
timePageUrl: beijingTimeProof.pageUrl,
|
||||
})
|
||||
} catch (error) {
|
||||
beijingTimeProof = {
|
||||
screenshotPath: '',
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
error: error instanceof Error ? error.message : '北京时间截图生成失败',
|
||||
}
|
||||
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
|
||||
}
|
||||
|
||||
await fs.writeFile(htmlPath, await page.content(), 'utf8')
|
||||
await writeRedeemResultFile(resultPath, {
|
||||
proofMode: effectiveProofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '',
|
||||
beijingTimePageUrl: beijingTimeProof.pageUrl,
|
||||
beijingTimeError: beijingTimeProof.error || '',
|
||||
})
|
||||
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
resultPath,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) {
|
||||
const normalized = String(rawMode || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (normalized === 'basic' || normalized === 'off') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return DEFAULT_REDEEM_PROOF_MODE
|
||||
}
|
||||
|
||||
function buildArtifactPaths(outputDir) {
|
||||
return {
|
||||
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
|
||||
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
|
||||
screenshotPath: path.join(outputDir, 'redeem-result.png'),
|
||||
htmlPath: path.join(outputDir, 'page.html'),
|
||||
resultPath: path.join(outputDir, 'result.json'),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRedeemResultFile(
|
||||
resultPath,
|
||||
{
|
||||
proofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
htmlPath = '',
|
||||
redeemPageScreenshotPath = '',
|
||||
beijingTimeScreenshotPath = '',
|
||||
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
|
||||
beijingTimeError = '',
|
||||
},
|
||||
) {
|
||||
await fs.writeFile(
|
||||
resultPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
proofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
area: finalResult?.role?.area || '',
|
||||
final: finalResult,
|
||||
attempts,
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath,
|
||||
beijingTimePageUrl,
|
||||
beijingTimeError,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
async function showResultDialog(page, message) {
|
||||
await page.evaluate((text) => {
|
||||
document.querySelectorAll('iframe').forEach((element) => element.remove())
|
||||
document.querySelectorAll('.pop').forEach((element) => {
|
||||
element.style.display = 'none'
|
||||
})
|
||||
|
||||
let card = document.getElementById('codex-redeem-result')
|
||||
|
||||
if (!card) {
|
||||
card = document.createElement('div')
|
||||
card.id = 'codex-redeem-result'
|
||||
card.innerHTML = `
|
||||
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
|
||||
<div class="codex-redeem-result__title">兑换结果</div>
|
||||
<div class="codex-redeem-result__message"></div>
|
||||
`
|
||||
document.body.appendChild(card)
|
||||
}
|
||||
|
||||
const messageNode = card.querySelector('.codex-redeem-result__message')
|
||||
|
||||
if (messageNode) {
|
||||
messageNode.textContent = text
|
||||
}
|
||||
|
||||
Object.assign(card.style, {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '99999',
|
||||
width: 'min(560px, calc(100vw - 80px))',
|
||||
padding: '32px 36px',
|
||||
borderRadius: '24px',
|
||||
background: 'rgba(10, 18, 28, 0.92)',
|
||||
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
|
||||
border: '1px solid rgba(109, 241, 202, 0.25)',
|
||||
color: '#f4fbff',
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
})
|
||||
|
||||
const styleId = 'codex-redeem-result-style'
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
#codex-redeem-result .codex-redeem-result__eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6df1ca;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
#codex-redeem-result .codex-redeem-result__title {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#codex-redeem-result .codex-redeem-result__message {
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0)
|
||||
}, message)
|
||||
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
async function captureBeijingTimeProof(browserContext, { screenshotPath }) {
|
||||
const proofPage = await browserContext.newPage()
|
||||
|
||||
try {
|
||||
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
|
||||
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
|
||||
await proofPage.waitForTimeout(3_000)
|
||||
await proofPage.evaluate(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
await proofPage.screenshot({
|
||||
path: screenshotPath,
|
||||
clip: BEIJING_TIME_PROOF_CLIP,
|
||||
})
|
||||
|
||||
return {
|
||||
screenshotPath,
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
}
|
||||
} finally {
|
||||
await proofPage.close().catch(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
async function composeProofScreenshot(
|
||||
browserContext,
|
||||
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl },
|
||||
) {
|
||||
const [redeemImageBase64, timeImageBase64] = await Promise.all([
|
||||
fs.readFile(redeemImagePath, 'base64'),
|
||||
fs.readFile(timeImagePath, 'base64'),
|
||||
])
|
||||
|
||||
const composePage = await browserContext.newPage()
|
||||
|
||||
try {
|
||||
await composePage.setViewportSize({ width: 1520, height: 1900 })
|
||||
await composePage.setContent(
|
||||
buildProofHtml({
|
||||
redeemImageBase64,
|
||||
timeImageBase64,
|
||||
redeemMessage,
|
||||
timePageUrl,
|
||||
}),
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
)
|
||||
await composePage.screenshot({ path: outputPath, fullPage: true })
|
||||
} finally {
|
||||
await composePage.close().catch(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) {
|
||||
const capturedAt = new Date().toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>兑换与北京时间凭证</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
|
||||
color: #142033;
|
||||
}
|
||||
.sheet {
|
||||
width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 24px 28px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
padding: 0 6px 18px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: #1c78d0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 44px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.summary {
|
||||
margin: 14px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
color: #52627a;
|
||||
}
|
||||
.meta {
|
||||
min-width: 320px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
|
||||
}
|
||||
.meta strong,
|
||||
.meta span {
|
||||
display: block;
|
||||
}
|
||||
.meta strong {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #60748d;
|
||||
}
|
||||
.meta span {
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
color: #142033;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0;
|
||||
color: #5a6b83;
|
||||
line-height: 1.65;
|
||||
font-size: 16px;
|
||||
}
|
||||
.preview {
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
background: #dfe7f3;
|
||||
}
|
||||
.preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.preview--time {
|
||||
max-height: 500px;
|
||||
}
|
||||
.preview--time img {
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
.url {
|
||||
margin-top: 10px;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 14px;
|
||||
color: #58708f;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="sheet">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Tencent Redeem Proof</p>
|
||||
<h1>兑换截图与北京时间截图</h1>
|
||||
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<strong>Captured At</strong>
|
||||
<span>${escapeHtml(capturedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>兑换结果截图</h2>
|
||||
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
|
||||
<div class="preview">
|
||||
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>百度检索“北京时间”截图</h2>
|
||||
<p>单独标签页打开百度搜索结果并截图。</p>
|
||||
<div class="url">${escapeHtml(timePageUrl)}</div>
|
||||
<div class="preview preview--time">
|
||||
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
|
||||
|
||||
const QQ_QR_TARGET_SIZE = 144
|
||||
|
||||
export async function ensureQqLoginReady(page) {
|
||||
try {
|
||||
const frame = await waitForFrame(
|
||||
page,
|
||||
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
|
||||
10_000,
|
||||
)
|
||||
const switcher = frame.locator('#switcher_qlogin')
|
||||
|
||||
if (!(await switcher.count())) {
|
||||
return
|
||||
}
|
||||
|
||||
await switcher.click({ timeout: 3_000 }).catch(() => null)
|
||||
await frame.waitForTimeout(250)
|
||||
} catch {
|
||||
// ignore qq inner tab switch failures; the screenshot retry path will try again
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureQqQrImage(page, qrImagePath) {
|
||||
const frame = await waitForFrame(
|
||||
page,
|
||||
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
|
||||
10_000,
|
||||
)
|
||||
const qrUrl = await resolveQqQrImageUrl(frame)
|
||||
const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl)
|
||||
|
||||
logBrowserSessionDebug('qq.capture.qrUrl', {
|
||||
qrUrl,
|
||||
effectiveQrUrl,
|
||||
frameUrl: frame.url(),
|
||||
})
|
||||
|
||||
if (effectiveQrUrl) {
|
||||
const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq')
|
||||
logBrowserSessionDebug('qq.capture.downloadResult', {
|
||||
qrUrl: effectiveQrUrl,
|
||||
downloaded: Boolean(body?.length),
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (body?.length) {
|
||||
await fs.writeFile(qrImagePath, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logBrowserSessionDebug('qq.capture.fallbackScreenshot', {
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
const qrLocator = await findQqQrLocator(page, frame)
|
||||
|
||||
try {
|
||||
await qrLocator.screenshot({ path: qrImagePath })
|
||||
return
|
||||
} catch {
|
||||
const iframeLocator = page.locator('#milo-qcwx-frame-qc').first()
|
||||
await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await iframeLocator.screenshot({ path: qrImagePath })
|
||||
}
|
||||
}
|
||||
|
||||
export async function findQqQrLocator(page, frame = null) {
|
||||
const effectiveFrame =
|
||||
frame ||
|
||||
await waitForFrame(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
|
||||
const locator = effectiveFrame.locator('#qrlogin_img')
|
||||
await locator.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
logBrowserSessionDebug('qq.findQrLocator.visibleReady', {
|
||||
frameUrl: effectiveFrame.url(),
|
||||
})
|
||||
return locator
|
||||
}
|
||||
|
||||
export async function extractQqQrState(page) {
|
||||
const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
|
||||
|
||||
if (!frame) {
|
||||
return {
|
||||
visible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const qrVisible = await frame
|
||||
.locator('#qrlogin_img')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible,
|
||||
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
|
||||
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
|
||||
message: bodyText.slice(0, 200),
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveQqQrImageUrl(frame) {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const qrImage = document.querySelector('#qrlogin_img')
|
||||
|
||||
if (!(qrImage instanceof HTMLImageElement)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
|
||||
})
|
||||
.then((src) => {
|
||||
if (!src) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, frame.url()).toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.catch(() => '')
|
||||
}
|
||||
|
||||
function upgradeQqQrImageUrl(qrUrl) {
|
||||
if (!qrUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(qrUrl)
|
||||
|
||||
if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) {
|
||||
return qrUrl
|
||||
}
|
||||
|
||||
const currentSize = Number(url.searchParams.get('d') || 0)
|
||||
|
||||
if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) {
|
||||
url.searchParams.set('d', String(QQ_QR_TARGET_SIZE))
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
} catch {
|
||||
return qrUrl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
export async function runTencentBrowserRedeem({
|
||||
session,
|
||||
code,
|
||||
maxAttempts,
|
||||
ensureLoggedInPresentation,
|
||||
ensureActivityInfoReady,
|
||||
fillRedeemCodeInBrowser,
|
||||
capturePageCaptchaForOcr,
|
||||
recognizeTencentCaptcha,
|
||||
submitRedeemInBrowser,
|
||||
isCaptchaRejectedResult,
|
||||
refreshPageCaptcha,
|
||||
persistSessionState,
|
||||
buildSessionPayload,
|
||||
saveRedeemArtifacts,
|
||||
activityUrl,
|
||||
}) {
|
||||
session.status = 'redeeming'
|
||||
session.notice = '正在识别验证码并提交兑换'
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
const attempts = []
|
||||
let finalResult = null
|
||||
|
||||
try {
|
||||
await ensureLoggedInPresentation(session, { credentialReady: true })
|
||||
const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true })
|
||||
|
||||
if (!activityInfo?.role?.ready) {
|
||||
throw new Error('浏览器会话尚未拿到角色信息,请稍后重试')
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
await fillRedeemCodeInBrowser(session.page, code)
|
||||
|
||||
const captcha = await capturePageCaptchaForOcr(session.page, {
|
||||
sessionDir: session.sessionDir,
|
||||
attempt,
|
||||
})
|
||||
const ocr = await recognizeTencentCaptcha({
|
||||
imageBase64: captcha.imageBuffer.toString('base64'),
|
||||
imageExtension: captcha.imageExtension,
|
||||
imageContentType: captcha.contentType,
|
||||
finalUrl: captcha.imagePath,
|
||||
saveSample: true,
|
||||
tag: `browser-session-${session.sessionId}-attempt-${attempt}`,
|
||||
})
|
||||
|
||||
if (ocr?.code !== 0) {
|
||||
throw new Error(ocr?.msg || 'OCR 识别失败')
|
||||
}
|
||||
|
||||
const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim()
|
||||
|
||||
if (!verifyCode) {
|
||||
throw new Error('OCR 没有识别出验证码')
|
||||
}
|
||||
|
||||
const redeem = await submitRedeemInBrowser(session.page, {
|
||||
code,
|
||||
verifyCode,
|
||||
})
|
||||
|
||||
const attemptRecord = {
|
||||
attempt,
|
||||
verifyCode,
|
||||
verifysession: captcha.verifysession,
|
||||
ocrSample: ocr?.data?.saved || null,
|
||||
redeem,
|
||||
role: activityInfo.role,
|
||||
}
|
||||
|
||||
attempts.push(attemptRecord)
|
||||
finalResult = attemptRecord
|
||||
|
||||
if (!isCaptchaRejectedResult(redeem)) {
|
||||
break
|
||||
}
|
||||
|
||||
await dismissRedeemRetryPopup(session.page)
|
||||
await refreshPageCaptcha(session.page, captcha.verifyImgId)
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('浏览器会话兑换没有拿到结果')
|
||||
}
|
||||
|
||||
const artifacts = await saveRedeemArtifacts({
|
||||
browserContext: session.browserContext,
|
||||
page: session.page,
|
||||
sessionDir: session.sessionDir,
|
||||
sessionId: session.sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
})
|
||||
|
||||
session.lastRedeem = {
|
||||
code,
|
||||
area: finalResult?.role?.area || '',
|
||||
attempts,
|
||||
final: finalResult,
|
||||
proofMode: artifacts.proofMode,
|
||||
screenshotPath: artifacts.screenshotPath,
|
||||
htmlPath: artifacts.htmlPath,
|
||||
resultPath: artifacts.resultPath,
|
||||
finishedAt: new Date().toISOString(),
|
||||
}
|
||||
session.status = 'redeemed'
|
||||
session.notice = String(finalResult.redeem?.sMsg || '兑换完成')
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
return {
|
||||
...buildSessionPayload(session),
|
||||
redeem: session.lastRedeem,
|
||||
}
|
||||
} catch (error) {
|
||||
session.status = 'failed'
|
||||
session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败'
|
||||
session.lastError = session.notice
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensureActivityInfoReady }) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 })
|
||||
|
||||
if (!activityInfo?.form?.verifyImgId) {
|
||||
throw new Error('页面里没有找到验证码图片节点')
|
||||
}
|
||||
|
||||
const verifySelector = `#${activityInfo.form.verifyImgId}`
|
||||
|
||||
await page.waitForFunction(
|
||||
(selector) => {
|
||||
const element = document.querySelector(selector)
|
||||
|
||||
if (!(element instanceof HTMLImageElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
element.naturalWidth > 0
|
||||
)
|
||||
},
|
||||
verifySelector,
|
||||
{ timeout: 8_000 },
|
||||
)
|
||||
|
||||
const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png`
|
||||
await page.locator(verifySelector).screenshot({ path: imagePath })
|
||||
|
||||
const verifysession = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.Milo && typeof window.Milo.get === 'function') {
|
||||
return String(window.Milo.get('verifysession') || '')
|
||||
}
|
||||
} catch {
|
||||
// ignore Milo access failures
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
return {
|
||||
imageBuffer: await fs.readFile(imagePath),
|
||||
imagePath,
|
||||
imageExtension: '.png',
|
||||
contentType: 'image/png',
|
||||
verifyImgId: activityInfo.form.verifyImgId,
|
||||
verifysession,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoReady }) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 })
|
||||
|
||||
if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) {
|
||||
throw new Error('页面里没有找到兑换输入框或提交按钮')
|
||||
}
|
||||
|
||||
const verifySelector = `#${activityInfo.form.verifyInputId}`
|
||||
const submitSelector = `#${activityInfo.form.submitId}`
|
||||
|
||||
await setFormControlValue(page, verifySelector, payload.verifyCode)
|
||||
await resetRedeemPopup(page)
|
||||
await normalizeRedeemOverlay(page)
|
||||
|
||||
const responsePromise = page
|
||||
.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('dfm.ams.game.qq.com/ide/') &&
|
||||
response.request().method().toUpperCase() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null)
|
||||
const popupPromise = waitForRedeemPopup(page).catch(() => null)
|
||||
|
||||
await clickRedeemSubmit(page, submitSelector)
|
||||
|
||||
const networkResponse = await responsePromise
|
||||
const popup = await popupPromise
|
||||
const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null
|
||||
|
||||
if (parsedNetwork && typeof parsedNetwork === 'object') {
|
||||
return {
|
||||
httpStatus: networkResponse.status(),
|
||||
popup: popup || null,
|
||||
...parsedNetwork,
|
||||
}
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('页面内兑换没有等到结果弹窗或接口响应')
|
||||
}
|
||||
|
||||
return {
|
||||
httpStatus: networkResponse?.status?.() || 0,
|
||||
iRet: inferRedeemCodeFromPopup(popup),
|
||||
sMsg: popup.text || popup.detail || '兑换完成',
|
||||
popup,
|
||||
}
|
||||
}
|
||||
|
||||
export function fillRedeemCodeInBrowser(page, code, { activityInfo }) {
|
||||
const cdkeySelector = activityInfo?.form?.cdkeyInputId
|
||||
? `#${activityInfo.form.cdkeyInputId}`
|
||||
: '[id^="milo_cdkeyInfo_"]'
|
||||
|
||||
const verifySelector = activityInfo?.form?.verifyInputId
|
||||
? `#${activityInfo.form.verifyInputId}`
|
||||
: '[id^="milo_verifyInput_"]'
|
||||
|
||||
return Promise.all([
|
||||
setFormControlValue(page, cdkeySelector, code),
|
||||
setFormControlValue(page, verifySelector, ''),
|
||||
])
|
||||
}
|
||||
|
||||
export async function prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
|
||||
await fillRedeemCodeInBrowser(page, code, { activityInfo })
|
||||
}
|
||||
|
||||
export async function refreshPageCaptcha(page, verifyImgId) {
|
||||
if (!verifyImgId) {
|
||||
return
|
||||
}
|
||||
|
||||
const selector = `#${verifyImgId}`
|
||||
const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '')
|
||||
await page.locator(selector).click({ timeout: 3_000 }).catch(() => null)
|
||||
await page.waitForFunction(
|
||||
({ selector: targetSelector, previousSrc }) => {
|
||||
const element = document.querySelector(targetSelector)
|
||||
|
||||
if (!(element instanceof HTMLImageElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '')
|
||||
},
|
||||
{ selector, previousSrc: currentSrc || '' },
|
||||
{ timeout: 5_000 },
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
export function isCaptchaRejectedResult(result) {
|
||||
const retCode = Number(result?.iRet)
|
||||
|
||||
if (Number.isFinite(retCode) && retCode === -100) {
|
||||
return true
|
||||
}
|
||||
|
||||
const text = [
|
||||
String(result?.sMsg || ''),
|
||||
String(result?.msg || ''),
|
||||
String(result?.popup?.text || ''),
|
||||
String(result?.popup?.detail || ''),
|
||||
]
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
return /验证码|校验码/.test(text)
|
||||
}
|
||||
|
||||
function resetRedeemPopup(page) {
|
||||
return page.evaluate(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
const popupDetail = document.querySelector('#PopText2')
|
||||
|
||||
if (popup instanceof HTMLElement) {
|
||||
popup.style.display = 'none'
|
||||
}
|
||||
|
||||
if (popupText instanceof HTMLElement) {
|
||||
popupText.textContent = ''
|
||||
}
|
||||
|
||||
if (popupDetail instanceof HTMLElement) {
|
||||
popupDetail.classList.add('hide')
|
||||
popupDetail.textContent = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function dismissRedeemRetryPopup(page) {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
if (typeof window.closeDialog === 'function') {
|
||||
window.closeDialog()
|
||||
}
|
||||
} catch {
|
||||
// ignore page close hook failures
|
||||
}
|
||||
|
||||
const closeButton = document.querySelector('#pop2 .pop_close')
|
||||
|
||||
if (closeButton instanceof HTMLElement) {
|
||||
closeButton.click()
|
||||
}
|
||||
|
||||
const popup = document.querySelector('#pop2')
|
||||
|
||||
if (popup instanceof HTMLElement) {
|
||||
popup.style.display = 'none'
|
||||
popup.style.visibility = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
await normalizeRedeemOverlay(page)
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
|
||||
async function normalizeRedeemOverlay(page) {
|
||||
await page.evaluate(() => {
|
||||
const overlayIds = ['_overlay_', 'overlay_mask', 'overlay']
|
||||
|
||||
for (const id of overlayIds) {
|
||||
const element = document.getElementById(id)
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
|
||||
element.style.pointerEvents = 'none'
|
||||
element.style.display = 'none'
|
||||
element.style.opacity = '0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function clickRedeemSubmit(page, submitSelector) {
|
||||
const locator = page.locator(submitSelector)
|
||||
|
||||
try {
|
||||
await locator.click({ timeout: 3_000 })
|
||||
return
|
||||
} catch {
|
||||
await normalizeRedeemOverlay(page)
|
||||
}
|
||||
|
||||
try {
|
||||
await locator.click({ timeout: 3_000, force: true })
|
||||
return
|
||||
} catch {
|
||||
await locator.evaluate((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setFormControlValue(page, selector, value) {
|
||||
const nextValue = String(value ?? '')
|
||||
|
||||
const appliedValue = await page.evaluate(
|
||||
({ targetSelector, targetValue }) => {
|
||||
const isVisible = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
element.getClientRects().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
const matches = [...document.querySelectorAll(targetSelector)]
|
||||
const element =
|
||||
matches.find(
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden' &&
|
||||
isVisible(node),
|
||||
) ||
|
||||
matches.find(
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden',
|
||||
) ||
|
||||
null
|
||||
|
||||
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
found: false,
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
|
||||
const prototype =
|
||||
element instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
|
||||
|
||||
if (descriptor?.set) {
|
||||
descriptor.set.call(element, targetValue)
|
||||
} else {
|
||||
element.value = targetValue
|
||||
}
|
||||
|
||||
element.setAttribute('value', targetValue)
|
||||
element.focus()
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }))
|
||||
element.blur()
|
||||
|
||||
return {
|
||||
found: true,
|
||||
value: String(element.value || ''),
|
||||
}
|
||||
},
|
||||
{ targetSelector: selector, targetValue: nextValue },
|
||||
)
|
||||
|
||||
if (!appliedValue?.found) {
|
||||
throw new Error(`页面里没有找到可填写的表单节点: ${selector}`)
|
||||
}
|
||||
|
||||
if (String(appliedValue.value || '') !== nextValue) {
|
||||
throw new Error(`页面表单写值失败: ${selector}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRedeemPopup(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
|
||||
if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(popup)
|
||||
return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
return page.evaluate(() => ({
|
||||
visible: true,
|
||||
text: String(document.querySelector('#PopText')?.textContent || '').trim(),
|
||||
detail: String(document.querySelector('#PopText2')?.textContent || '').trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inferRedeemCodeFromPopup(popup) {
|
||||
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
|
||||
|
||||
if (!text) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (/验证码|校验码/.test(text)) {
|
||||
return -100
|
||||
}
|
||||
|
||||
if (/成功|已兑换|领取成功/.test(text)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
|
||||
|
||||
export async function ensureWxLoginReady(page) {
|
||||
try {
|
||||
const frame = await waitForFrame(
|
||||
page,
|
||||
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
|
||||
10_000,
|
||||
)
|
||||
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
const viewState = await inspectWxLoginView(frame)
|
||||
logBrowserSessionDebug('wx.ensureQrMode.viewState', {
|
||||
attempt,
|
||||
frameUrl: frame.url(),
|
||||
qrVisible: viewState.qrVisible,
|
||||
quickLoginVisible: viewState.quickLoginVisible,
|
||||
switchToNormalVisible: viewState.switchToNormalVisible,
|
||||
qrCandidates: viewState.qrCandidates,
|
||||
bodyText: viewState.bodyText.slice(0, 160),
|
||||
})
|
||||
|
||||
if (viewState.qrVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
viewState.quickLoginVisible ||
|
||||
viewState.switchToNormalVisible ||
|
||||
/使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText)
|
||||
) {
|
||||
const switched = await switchWxQuickLoginToQr(frame)
|
||||
logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', {
|
||||
attempt,
|
||||
switched,
|
||||
})
|
||||
|
||||
if (switched) {
|
||||
await frame.waitForTimeout(700)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await frame.waitForTimeout(400)
|
||||
}
|
||||
} catch {
|
||||
// ignore wx quick-login switch failures; the screenshot retry path will try again
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureWxQrImage(page, qrImagePath) {
|
||||
const qrLocator = await findWxQrLocator(page)
|
||||
const frame = await waitForFrame(
|
||||
page,
|
||||
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
|
||||
10_000,
|
||||
)
|
||||
|
||||
const qrUrl = await resolveWxQrImageUrl(frame)
|
||||
logBrowserSessionDebug('wx.capture.qrUrl', {
|
||||
qrUrl,
|
||||
frameUrl: frame.url(),
|
||||
})
|
||||
|
||||
if (qrUrl) {
|
||||
const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx')
|
||||
logBrowserSessionDebug('wx.capture.downloadResult', {
|
||||
qrUrl,
|
||||
downloaded: Boolean(downloaded?.length),
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (downloaded?.length) {
|
||||
await fs.writeFile(qrImagePath, downloaded)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logBrowserSessionDebug('wx.capture.fallbackScreenshot', {
|
||||
qrImagePath,
|
||||
})
|
||||
await qrLocator.screenshot({ path: qrImagePath })
|
||||
}
|
||||
|
||||
export async function findWxQrLocator(page) {
|
||||
await ensureWxLoginReady(page)
|
||||
|
||||
const frame = await waitForFrame(
|
||||
page,
|
||||
(item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'),
|
||||
)
|
||||
const viewState = await inspectWxLoginView(frame)
|
||||
logBrowserSessionDebug('wx.findQrLocator.beforeWait', {
|
||||
frameUrl: frame.url(),
|
||||
qrVisible: viewState.qrVisible,
|
||||
qrCandidates: viewState.qrCandidates,
|
||||
bodyText: viewState.bodyText.slice(0, 160),
|
||||
})
|
||||
|
||||
const locator = frame.locator('.js_qrcode_img:visible').first()
|
||||
await locator.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
logBrowserSessionDebug('wx.findQrLocator.visibleReady', {
|
||||
frameUrl: frame.url(),
|
||||
})
|
||||
return locator
|
||||
}
|
||||
|
||||
export async function extractWxQrState(page) {
|
||||
const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'))
|
||||
|
||||
if (!frame) {
|
||||
return {
|
||||
visible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const qrVisible = await frame
|
||||
.locator('.js_qrcode_img')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim()
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible,
|
||||
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
|
||||
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
|
||||
message: bodyText.slice(0, 200),
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectWxLoginView(frame) {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const isVisible = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
const quickLogin = document.querySelector('.js_quick_login')
|
||||
const switchToNormal = document.querySelector('.js_switchToNormal')
|
||||
const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => {
|
||||
if (!(item instanceof HTMLImageElement)) {
|
||||
return {
|
||||
index,
|
||||
visible: false,
|
||||
src: '',
|
||||
naturalWidth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
visible: isVisible(item),
|
||||
src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(),
|
||||
naturalWidth: Number(item.naturalWidth || 0),
|
||||
}
|
||||
})
|
||||
const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0)
|
||||
|
||||
return {
|
||||
qrVisible: Boolean(visibleQrCandidate),
|
||||
quickLoginVisible: isVisible(quickLogin),
|
||||
switchToNormalVisible: isVisible(switchToNormal),
|
||||
qrCandidates,
|
||||
bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(),
|
||||
}
|
||||
})
|
||||
.catch(() => ({
|
||||
qrVisible: false,
|
||||
quickLoginVisible: false,
|
||||
switchToNormalVisible: false,
|
||||
qrCandidates: [],
|
||||
bodyText: '',
|
||||
}))
|
||||
}
|
||||
|
||||
async function switchWxQuickLoginToQr(frame) {
|
||||
const switcherCandidates = [
|
||||
frame.locator('.js_switchToNormal:visible').first(),
|
||||
frame.locator('.js_switchToNormal').first(),
|
||||
frame.locator('button:has-text("使用其他头像、昵称或账号")').first(),
|
||||
frame.locator('text=使用其他头像、昵称或账号').first(),
|
||||
]
|
||||
|
||||
for (const candidate of switcherCandidates) {
|
||||
const visible = await candidate.isVisible().catch(() => false)
|
||||
|
||||
if (!visible) {
|
||||
continue
|
||||
}
|
||||
|
||||
logBrowserSessionDebug('wx.switchToNormal.candidateVisible', {
|
||||
candidateIndex: switcherCandidates.indexOf(candidate),
|
||||
})
|
||||
|
||||
const clicked =
|
||||
(await candidate
|
||||
.click({ timeout: 2_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)) ||
|
||||
(await candidate
|
||||
.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
element.click()
|
||||
return true
|
||||
})
|
||||
.catch(() => false))
|
||||
|
||||
if (clicked) {
|
||||
logBrowserSessionDebug('wx.switchToNormal.clicked', {
|
||||
candidateIndex: switcherCandidates.indexOf(candidate),
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = String(element.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
/使用其他头像、昵称或账号/.test(text) &&
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
})
|
||||
|
||||
const target = candidates[0]
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
target.click()
|
||||
return true
|
||||
})
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
async function resolveWxQrImageUrl(frame) {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const isVisible = (element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img'))
|
||||
const qrImage =
|
||||
qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) ||
|
||||
qrImages.find((item) => item instanceof HTMLImageElement) ||
|
||||
null
|
||||
|
||||
if (!(qrImage instanceof HTMLImageElement)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
|
||||
})
|
||||
.then((src) => {
|
||||
if (!src) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, frame.url()).toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.catch(() => '')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js'
|
||||
import { upsertOrderFromWebhook } from './order-service.js'
|
||||
import { createHttpError } from '../utils/http.js'
|
||||
import { nowIso } from '../utils/time.js'
|
||||
|
||||
export async function processAgisoTradeWebhook(requestLike) {
|
||||
const parsed = parseAgisoTradeRequest(requestLike)
|
||||
const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed))
|
||||
|
||||
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
|
||||
}
|
||||
|
||||
export async function replayAgisoTradeWebhookEvent(webhookEvent) {
|
||||
const requestLike = {
|
||||
headers: safeParseJson(webhookEvent.headers_json),
|
||||
query: safeParseJson(webhookEvent.query_json),
|
||||
body: safeParseJson(webhookEvent.body_json),
|
||||
}
|
||||
const parsed = parseAgisoTradeRequest(requestLike)
|
||||
|
||||
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
|
||||
}
|
||||
|
||||
function buildWebhookEventInput(requestLike, parsed) {
|
||||
return {
|
||||
platform: 'agiso',
|
||||
eventType: parsed.eventType,
|
||||
eventKey: parsed.eventKey,
|
||||
signatureValid: parsed.signatureValid,
|
||||
headersJson: JSON.stringify(requestLike.headers || {}),
|
||||
queryJson: JSON.stringify(requestLike.query || {}),
|
||||
bodyJson: JSON.stringify(requestLike.body || {}),
|
||||
processed: false,
|
||||
processError: '',
|
||||
relatedOrderId: null,
|
||||
createdAt: nowIso(),
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAgisoTradeWebhook(parsed, webhookEventId) {
|
||||
try {
|
||||
if (!parsed.signatureValid) {
|
||||
throw createHttpError('验签失败', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_signature',
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsed.platformOrderId) {
|
||||
throw createHttpError('缺少平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await upsertOrderFromWebhook(parsed)
|
||||
updateWebhookEvent(webhookEventId, {
|
||||
processed: true,
|
||||
process_error: '',
|
||||
related_order_id: result.order.id,
|
||||
})
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
eventType: parsed.eventType,
|
||||
platformOrderId: parsed.platformOrderId,
|
||||
orderId: result.order.id,
|
||||
taskCount: result.tasks.length,
|
||||
messageDeliveries: result.messageDeliveries || [],
|
||||
tasks: result.tasks.map((task) => ({
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
updateWebhookEvent(webhookEventId, {
|
||||
processed: false,
|
||||
process_error: error instanceof Error ? error.message : String(error || ''),
|
||||
related_order_id: null,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgisoTradeRequest(requestLike) {
|
||||
const query = normalizeRecord(requestLike.query)
|
||||
const body = normalizeRecord(requestLike.body)
|
||||
const rawJson = String(body.json || '').trim()
|
||||
const payload = rawJson ? parsePayloadJson(rawJson) : body
|
||||
const timestamp = String(query.timestamp || '').trim()
|
||||
const sign = String(query.sign || '').trim().toLowerCase()
|
||||
const eventType = resolveEventType(query.aopic)
|
||||
const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign })
|
||||
const platformOrderId = pickFirstNonEmpty([
|
||||
payload.biz_order_id,
|
||||
payload.Tid,
|
||||
payload.tid,
|
||||
payload.order_id,
|
||||
payload.orderId,
|
||||
])
|
||||
|
||||
return {
|
||||
platform: 'agiso',
|
||||
eventType,
|
||||
eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`,
|
||||
signatureValid,
|
||||
platformOrderId,
|
||||
orderStatus: resolveOrderStatus(eventType, payload),
|
||||
payStatus: resolvePayStatus(eventType, payload),
|
||||
buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]),
|
||||
buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]),
|
||||
receiverContact: pickFirstNonEmpty([
|
||||
payload.receiver_contact,
|
||||
payload.receiverContact,
|
||||
payload.mobile,
|
||||
payload.phone,
|
||||
]),
|
||||
totalAmount: normalizeInteger(
|
||||
pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]),
|
||||
),
|
||||
currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY',
|
||||
paidAt: resolvePaidAt(eventType, payload),
|
||||
rawPayload: payload,
|
||||
items: normalizeOrderItems(payload),
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAgisoSignature({ rawJson, timestamp, sign }) {
|
||||
const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim()
|
||||
|
||||
if (!appSecret) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!rawJson || !timestamp || !sign) {
|
||||
return false
|
||||
}
|
||||
|
||||
const documentedDigest = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
if (documentedDigest === sign) {
|
||||
return true
|
||||
}
|
||||
|
||||
const legacyDigest = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
return legacyDigest === sign
|
||||
}
|
||||
|
||||
function resolveEventType(aopic) {
|
||||
const normalized = String(aopic || '').trim()
|
||||
|
||||
if (normalized === '1') {
|
||||
return 'payment_success'
|
||||
}
|
||||
|
||||
if (normalized === '32') {
|
||||
return 'trade_create'
|
||||
}
|
||||
|
||||
if (normalized === '256') {
|
||||
return 'buyer_confirm_goods'
|
||||
}
|
||||
|
||||
if (normalized === '128') {
|
||||
return 'trade_closed'
|
||||
}
|
||||
|
||||
return 'trade_event'
|
||||
}
|
||||
|
||||
function resolveOrderStatus(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (eventType === 'trade_closed') {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (rawStatus === 6) {
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
if (rawStatus === 5) {
|
||||
return 'refunded'
|
||||
}
|
||||
|
||||
return 'created'
|
||||
}
|
||||
|
||||
function resolvePayStatus(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (eventType === 'trade_closed') {
|
||||
return 'unpaid'
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return 'paid'
|
||||
}
|
||||
|
||||
if (rawStatus === 5) {
|
||||
return 'refunded'
|
||||
}
|
||||
|
||||
return 'unpaid'
|
||||
}
|
||||
|
||||
function resolvePaidAt(eventType, payload) {
|
||||
if (eventType === 'payment_success') {
|
||||
return nowIso()
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 3 || rawStatus === 4) {
|
||||
return nowIso()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePayloadJson(rawJson) {
|
||||
if (!rawJson) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson)
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
throw createHttpError('json 参数不是合法 JSON', {
|
||||
statusCode: 400,
|
||||
errorCode: 'invalid_json_payload',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderItems(payload) {
|
||||
const items = Array.isArray(payload.items) && payload.items.length > 0
|
||||
? payload.items
|
||||
: [payload]
|
||||
|
||||
return items.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const rawSkuKey = pickFirstNonEmpty([
|
||||
source.sku_code,
|
||||
source.skuCode,
|
||||
source.goods_sku,
|
||||
source.item_id,
|
||||
source.itemId,
|
||||
source.goods_id,
|
||||
source.goodsId,
|
||||
source.num_iid,
|
||||
])
|
||||
const skuCode = resolveSkuCode(rawSkuKey)
|
||||
|
||||
return {
|
||||
skuCode,
|
||||
skuName: pickFirstNonEmpty([
|
||||
source.sku_name,
|
||||
source.skuName,
|
||||
source.goods_name,
|
||||
source.goodsName,
|
||||
skuCode,
|
||||
]),
|
||||
quantity: Math.max(
|
||||
1,
|
||||
normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1,
|
||||
),
|
||||
spec: source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveSkuCode(rawKey) {
|
||||
const mappings = runtimeConfig.orders.skuMappings
|
||||
|
||||
if (rawKey && mappings && typeof mappings === 'object') {
|
||||
const direct = mappings[String(rawKey)]
|
||||
if (typeof direct === 'string' && direct.trim()) {
|
||||
return direct.trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawKey === 'string' && rawKey.trim()) {
|
||||
return rawKey.trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeRecord(value) {
|
||||
return isPlainObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function normalizeInteger(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : 0
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
function safeParseJson(rawValue) {
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
return isPlainObject(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user