refactor backend route and fulfillment modules
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
import express from "express";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import adminRouter from "./routes/admin.js";
|
||||||
|
import claimsRouter from "./routes/claims.js";
|
||||||
|
import open91Router from "./routes/open-91.js";
|
||||||
|
import tencentRouter from "./routes/tencent.js";
|
||||||
|
import webhooksRouter from "./routes/webhooks.js";
|
||||||
|
import { buildSuccessPayload } from "./utils/http.js";
|
||||||
|
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||||
|
import { corsMiddleware } from "./middleware/cors.js";
|
||||||
|
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||||
|
|
||||||
|
type CreateAppOptions = {
|
||||||
|
startupState: StartupState;
|
||||||
|
isShutdownStarted: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createApp({ startupState, isShutdownStarted }: CreateAppOptions) {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(accessLogMiddleware);
|
||||||
|
app.use(corsMiddleware);
|
||||||
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
res.json(
|
||||||
|
buildSuccessPayload(
|
||||||
|
buildHealthPayload(startupState, isShutdownStarted()),
|
||||||
|
startupState.core.ready ? "ready" : "starting"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health/live", (_req, res) => {
|
||||||
|
res.json(
|
||||||
|
buildSuccessPayload({
|
||||||
|
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||||
|
pid: process.pid,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health/ready", (_req, res) => {
|
||||||
|
if (startupState.core.ready) {
|
||||||
|
res.json(buildSuccessPayload(buildHealthPayload(startupState, isShutdownStarted()), "ready"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(503).json({
|
||||||
|
code: 1,
|
||||||
|
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
||||||
|
errorCode: "service_not_ready",
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
data: buildHealthPayload(startupState, isShutdownStarted()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (startupState.core.ready) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(503).json({
|
||||||
|
code: 1,
|
||||||
|
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
||||||
|
errorCode: "service_not_ready",
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
data: {
|
||||||
|
startup: {
|
||||||
|
phase: startupState.phase,
|
||||||
|
attemptCount: startupState.core.attemptCount,
|
||||||
|
lastAttemptAt: startupState.core.lastAttemptAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api/v1/tencent", tencentRouter);
|
||||||
|
app.use("/api/v1/open/91", open91Router);
|
||||||
|
app.use("/api/v1/webhooks", webhooksRouter);
|
||||||
|
app.use("/api/v1/claim", claimsRouter);
|
||||||
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
+39
-397
@@ -1,415 +1,57 @@
|
|||||||
import express from 'express'
|
import process from "node:process";
|
||||||
import process from 'node:process'
|
|
||||||
|
|
||||||
import { runtimeConfig } from './config/runtime.js'
|
import { createApp } from "./app.js";
|
||||||
import { runDatabaseMigrations } from './db/migrate.js'
|
import { runtimeConfig } from "./config/runtime.js";
|
||||||
import adminRouter from './routes/admin.js'
|
import { bootstrapCoreServices } from "./startup/bootstrap.js";
|
||||||
import claimsRouter from './routes/claims.js'
|
import { createShutdownController } from "./startup/shutdown.js";
|
||||||
import open91Router from './routes/open-91.js'
|
import { createStartupState, formatStartupError } from "./startup/state.js";
|
||||||
import webhooksRouter from './routes/webhooks.js'
|
import { logError, logInfo } from "./utils/logger.js";
|
||||||
import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-service.js'
|
|
||||||
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
|
||||||
import { startScheduledJobs, stopScheduledJobs } from './services/scheduler/scheduler-service.js'
|
|
||||||
import { closeOcrService, warmupOcrService } from './services/session/ocr.js'
|
|
||||||
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
|
||||||
import { buildSuccessPayload } from './utils/http.js'
|
|
||||||
import { createRequestId, logError, logInfo, logWarn } from './utils/logger.js'
|
|
||||||
import tencentRouter from './routes/tencent.js'
|
|
||||||
|
|
||||||
const CORE_BOOT_RETRY_DELAY_MS = 5_000
|
const port = Number(runtimeConfig.server.port || 3000);
|
||||||
|
const host = "0.0.0.0";
|
||||||
|
const startupState = createStartupState();
|
||||||
|
|
||||||
const app = express()
|
let shutdownController: ReturnType<typeof createShutdownController> | null = null;
|
||||||
const port = Number(runtimeConfig.server.port || 3000)
|
|
||||||
const host = '0.0.0.0'
|
|
||||||
const startupState = createStartupState()
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
const app = createApp({
|
||||||
const startedAt = Date.now()
|
startupState,
|
||||||
const requestId = resolveRequestId(req)
|
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||||
|
});
|
||||||
req.requestId = requestId
|
|
||||||
res.setHeader('X-Request-Id', requestId)
|
|
||||||
|
|
||||||
res.on('finish', () => {
|
|
||||||
if (shouldSkipAccessLog(req.originalUrl, res.statusCode)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logInfo('[http/access]', 'request completed', {
|
|
||||||
requestId,
|
|
||||||
method: req.method,
|
|
||||||
originalUrl: req.originalUrl,
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
ip: req.ip,
|
|
||||||
forwardedFor: String(req.headers['x-forwarded-for'] || ''),
|
|
||||||
userAgent: String(req.headers['user-agent'] || ''),
|
|
||||||
referer: String(req.headers.referer || ''),
|
|
||||||
contentLength: Number(res.getHeader('content-length') || 0),
|
|
||||||
actorUserId: req.adminSession?.userId || '',
|
|
||||||
actorUsername: req.adminSession?.username || '',
|
|
||||||
actorRole: req.adminSession?.role || '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
next()
|
|
||||||
})
|
|
||||||
|
|
||||||
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(buildSuccessPayload(buildHealthPayload(), startupState.core.ready ? 'ready' : 'starting'))
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/health/live', (_req, res) => {
|
|
||||||
res.json(buildSuccessPayload({
|
|
||||||
status: shutdownStarted ? 'shutting_down' : 'alive',
|
|
||||||
pid: process.pid,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/health/ready', (_req, res) => {
|
|
||||||
if (startupState.core.ready) {
|
|
||||||
res.json(buildSuccessPayload(buildHealthPayload(), 'ready'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
code: 1,
|
|
||||||
msg: startupState.core.lastError || '服务启动中,请稍后重试',
|
|
||||||
errorCode: 'service_not_ready',
|
|
||||||
time: Math.floor(Date.now() / 1000),
|
|
||||||
data: buildHealthPayload(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (startupState.core.ready) {
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
code: 1,
|
|
||||||
msg: startupState.core.lastError || '服务启动中,请稍后重试',
|
|
||||||
errorCode: 'service_not_ready',
|
|
||||||
time: Math.floor(Date.now() / 1000),
|
|
||||||
data: {
|
|
||||||
startup: {
|
|
||||||
phase: startupState.phase,
|
|
||||||
attemptCount: startupState.core.attemptCount,
|
|
||||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use('/api/v1/tencent', tencentRouter)
|
|
||||||
app.use('/api/v1/open/91', open91Router)
|
|
||||||
app.use('/api/v1/webhooks', webhooksRouter)
|
|
||||||
app.use('/api/v1/claim', claimsRouter)
|
|
||||||
app.use('/api/v1/admin', adminRouter)
|
|
||||||
|
|
||||||
const server = app.listen(port, host, () => {
|
const server = app.listen(port, host, () => {
|
||||||
logInfo('[startup]', `order-site-backend listening on http://${host}:${port}`)
|
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
||||||
void bootstrapCoreServices()
|
void bootstrapCoreServices(
|
||||||
})
|
startupState,
|
||||||
|
() => shutdownController?.isShutdownStarted() || false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
server.on('error', (error) => {
|
shutdownController = createShutdownController(server, startupState);
|
||||||
logError('[startup]', 'HTTP server failed', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function bootstrapCoreServices() {
|
server.on("error", (error) => {
|
||||||
if (startupState.core.running || shutdownStarted) {
|
logError("[startup]", "HTTP server failed", error);
|
||||||
return
|
});
|
||||||
}
|
|
||||||
|
|
||||||
startupState.core.running = true
|
process.on("SIGINT", () => {
|
||||||
|
void shutdownController?.shutdown("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
while (!shutdownStarted && !startupState.core.ready) {
|
process.on("SIGTERM", () => {
|
||||||
startupState.phase = startupState.core.attemptCount === 0 ? 'starting' : 'retrying'
|
void shutdownController?.shutdown("SIGTERM");
|
||||||
startupState.core.attemptCount += 1
|
});
|
||||||
startupState.core.lastAttemptAt = new Date().toISOString()
|
|
||||||
|
|
||||||
try {
|
process.on("unhandledRejection", (reason) => {
|
||||||
logInfo('[startup]', '开始执行核心启动步骤', {
|
|
||||||
attempt: startupState.core.attemptCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
await runDatabaseMigrations()
|
|
||||||
await ensureFulfillmentCatalogBootstrapped()
|
|
||||||
await ensureAdminUsersBootstrapped()
|
|
||||||
|
|
||||||
startupState.core.ready = true
|
|
||||||
startupState.core.lastError = ''
|
|
||||||
startupState.phase = 'ready'
|
|
||||||
startupState.core.readyAt = new Date().toISOString()
|
|
||||||
|
|
||||||
logInfo('[startup]', '核心启动步骤完成,服务已就绪', {
|
|
||||||
attempt: startupState.core.attemptCount,
|
|
||||||
})
|
|
||||||
|
|
||||||
startScheduledJobs()
|
|
||||||
void bootstrapBrowser()
|
|
||||||
void bootstrapOcr()
|
|
||||||
break
|
|
||||||
} catch (error) {
|
|
||||||
const message = formatStartupError(error)
|
|
||||||
startupState.phase = 'retrying'
|
|
||||||
startupState.core.lastError = message
|
|
||||||
logError('[startup]', '核心启动步骤失败,将自动重试', {
|
|
||||||
attempt: startupState.core.attemptCount,
|
|
||||||
retryDelayMs: CORE_BOOT_RETRY_DELAY_MS,
|
|
||||||
error,
|
|
||||||
})
|
|
||||||
|
|
||||||
await sleep(CORE_BOOT_RETRY_DELAY_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startupState.core.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapBrowser() {
|
|
||||||
startupState.browser.status = 'starting'
|
|
||||||
startupState.browser.lastAttemptAt = new Date().toISOString()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await warmupTencentBrowser()
|
|
||||||
|
|
||||||
if (!result.warmed) {
|
|
||||||
startupState.browser.status = 'skipped'
|
|
||||||
startupState.browser.message = 'browser prewarm skipped'
|
|
||||||
logInfo('[startup]', 'browser prewarm skipped')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startupState.browser.status = 'ready'
|
|
||||||
startupState.browser.message = 'browser prewarm ready'
|
|
||||||
logInfo('[startup]', 'browser prewarm ready')
|
|
||||||
} catch (error) {
|
|
||||||
if (isMissingPlaywrightBrowserError(error)) {
|
|
||||||
const installCommand = resolveBrowserInstallCommand()
|
|
||||||
startupState.browser.status = 'degraded'
|
|
||||||
startupState.browser.message = formatErrorMessage(error)
|
|
||||||
logWarn('[startup]', `browser prewarm skipped: ${formatErrorMessage(error)}`)
|
|
||||||
logWarn('[startup]', `请先安装浏览器依赖: ${installCommand}`)
|
|
||||||
|
|
||||||
if (String(runtimeConfig.browser.chromePath || '').trim()) {
|
|
||||||
logWarn('[startup]', '当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在')
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startupState.browser.status = 'degraded'
|
|
||||||
startupState.browser.message = formatStartupError(error)
|
|
||||||
logError('[startup]', 'browser prewarm failed', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapOcr() {
|
|
||||||
startupState.ocr.status = 'starting'
|
|
||||||
startupState.ocr.lastAttemptAt = new Date().toISOString()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await warmupOcrService()
|
|
||||||
startupState.ocr.status = 'ready'
|
|
||||||
startupState.ocr.message = 'OCR service ready'
|
|
||||||
logInfo('[startup]', 'OCR service ready')
|
|
||||||
} catch (error) {
|
|
||||||
startupState.ocr.status = 'degraded'
|
|
||||||
startupState.ocr.message = formatStartupError(error)
|
|
||||||
logWarn('[startup]', `OCR service skipped: ${formatStartupError(error)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHealthPayload() {
|
|
||||||
return {
|
|
||||||
phase: startupState.phase,
|
|
||||||
startedAt: startupState.startedAt,
|
|
||||||
core: {
|
|
||||||
ready: startupState.core.ready,
|
|
||||||
running: startupState.core.running,
|
|
||||||
attemptCount: startupState.core.attemptCount,
|
|
||||||
readyAt: startupState.core.readyAt,
|
|
||||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
|
||||||
lastError: startupState.core.lastError,
|
|
||||||
},
|
|
||||||
browser: {
|
|
||||||
status: startupState.browser.status,
|
|
||||||
message: startupState.browser.message,
|
|
||||||
lastAttemptAt: startupState.browser.lastAttemptAt,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
status: startupState.ocr.status,
|
|
||||||
message: startupState.ocr.message,
|
|
||||||
lastAttemptAt: startupState.ocr.lastAttemptAt,
|
|
||||||
},
|
|
||||||
process: {
|
|
||||||
pid: process.pid,
|
|
||||||
shutdownStarted,
|
|
||||||
lastUnhandledRejection: startupState.process.lastUnhandledRejection,
|
|
||||||
lastUncaughtException: startupState.process.lastUncaughtException,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStartupState() {
|
|
||||||
return {
|
|
||||||
phase: 'starting',
|
|
||||||
startedAt: new Date().toISOString(),
|
|
||||||
core: {
|
|
||||||
ready: false,
|
|
||||||
running: false,
|
|
||||||
attemptCount: 0,
|
|
||||||
readyAt: '',
|
|
||||||
lastAttemptAt: '',
|
|
||||||
lastError: '',
|
|
||||||
},
|
|
||||||
browser: {
|
|
||||||
status: 'pending',
|
|
||||||
message: '',
|
|
||||||
lastAttemptAt: '',
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
status: 'pending',
|
|
||||||
message: '',
|
|
||||||
lastAttemptAt: '',
|
|
||||||
},
|
|
||||||
process: {
|
|
||||||
lastUnhandledRejection: null,
|
|
||||||
lastUncaughtException: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRequestId(req) {
|
|
||||||
const fromHeader = String(req.headers['x-request-id'] || '').trim()
|
|
||||||
return fromHeader || createRequestId('req')
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSkipAccessLog(originalUrl, statusCode) {
|
|
||||||
const pathname = String(originalUrl || '').split('?')[0]
|
|
||||||
return ['/health', '/health/live', '/health/ready'].includes(pathname) && Number(statusCode) < 400
|
|
||||||
}
|
|
||||||
|
|
||||||
let shutdownStarted = false
|
|
||||||
|
|
||||||
async function shutdown(signal) {
|
|
||||||
if (shutdownStarted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownStarted = true
|
|
||||||
startupState.phase = 'shutting_down'
|
|
||||||
logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`)
|
|
||||||
|
|
||||||
stopScheduledJobs()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await closeAllTencentBrowserSessions({ markClosed: true })
|
|
||||||
} catch (error) {
|
|
||||||
logError('[shutdown]', 'failed to close browser sessions', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await closeOcrService()
|
|
||||||
} catch (error) {
|
|
||||||
logError('[shutdown]', 'failed to close OCR service', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!server.listening) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
server.close((error) => {
|
|
||||||
if (error) {
|
|
||||||
logError('[shutdown]', 'failed to close HTTP server', error)
|
|
||||||
process.exitCode = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
void shutdown('SIGINT')
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
void shutdown('SIGTERM')
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
|
||||||
startupState.process.lastUnhandledRejection = {
|
startupState.process.lastUnhandledRejection = {
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: formatStartupError(reason),
|
message: formatStartupError(reason),
|
||||||
}
|
};
|
||||||
logError('[process]', 'unhandled promise rejection', reason)
|
logError("[process]", "unhandled promise rejection", reason);
|
||||||
})
|
});
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
startupState.process.lastUncaughtException = {
|
startupState.process.lastUncaughtException = {
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: formatStartupError(error),
|
message: formatStartupError(error),
|
||||||
}
|
};
|
||||||
logError('[process]', 'uncaught exception captured', error)
|
logError("[process]", "uncaught exception captured", error);
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createRequestId, logInfo } from "../utils/logger.js";
|
||||||
|
|
||||||
|
export function accessLogMiddleware(req, res, next) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const requestId = resolveRequestId(req);
|
||||||
|
|
||||||
|
req.requestId = requestId;
|
||||||
|
res.setHeader("X-Request-Id", requestId);
|
||||||
|
|
||||||
|
res.on("finish", () => {
|
||||||
|
if (shouldSkipAccessLog(req.originalUrl, res.statusCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo("[http/access]", "request completed", {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
originalUrl: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
ip: req.ip,
|
||||||
|
forwardedFor: String(req.headers["x-forwarded-for"] || ""),
|
||||||
|
userAgent: String(req.headers["user-agent"] || ""),
|
||||||
|
referer: String(req.headers.referer || ""),
|
||||||
|
contentLength: Number(res.getHeader("content-length") || 0),
|
||||||
|
actorUserId: req.adminSession?.userId || "",
|
||||||
|
actorUsername: req.adminSession?.username || "",
|
||||||
|
actorRole: req.adminSession?.role || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestId(req) {
|
||||||
|
const fromHeader = String(req.headers["x-request-id"] || "").trim();
|
||||||
|
return fromHeader || createRequestId("req");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipAccessLog(originalUrl, statusCode) {
|
||||||
|
const pathname = String(originalUrl || "").split("?")[0];
|
||||||
|
return ["/health", "/health/live", "/health/ready"].includes(pathname) && Number(statusCode) < 400;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function corsMiddleware(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();
|
||||||
|
}
|
||||||
@@ -1,922 +1,23 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
import { requireAdminRoles } from "./shared.js";
|
||||||
appointAdminCloudtentaclesVirtualNumber,
|
import agisoRouter from "./platform-config/agiso.js";
|
||||||
backAdminCloudtentaclesVirtualNumber,
|
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
||||||
buyAdminCloudtentaclesSku,
|
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
|
||||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
||||||
generateAdminCloudtentaclesLoginCode,
|
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||||
getAdminCloudtentaclesAsset,
|
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||||
getAdminCloudtentaclesBindUrl,
|
import notificationsRouter from "./platform-config/notifications.js";
|
||||||
getAdminCloudtentaclesCategories,
|
|
||||||
getAdminNotificationConfig,
|
|
||||||
getAdminScheduledJobsConfig,
|
|
||||||
getAdminKuaishouCloudFulfillmentConfig,
|
|
||||||
getAdminKuaishouEticketSourceConfig,
|
|
||||||
getAdminCloudtentaclesKnapsack,
|
|
||||||
listAdminCloudtentaclesSources,
|
|
||||||
deleteAdminCloudtentaclesSource,
|
|
||||||
getAdminCloudtentaclesSkuList,
|
|
||||||
useAdminCloudtentaclesSku,
|
|
||||||
getAdminAgisoShopConfigs,
|
|
||||||
getAdminFulfillmentBindingConfigs,
|
|
||||||
getAdminNinetyoneOrders,
|
|
||||||
consumeAdminKuaishouEticket,
|
|
||||||
failAdminNinetyoneOrder,
|
|
||||||
listAdminCloudtentaclesVirtualNumbers,
|
|
||||||
lookupAdminFulfillmentBindingOrder,
|
|
||||||
queryAdminKuaishouEticketShopInfo,
|
|
||||||
queryAdminKuaishouEticketDetail,
|
|
||||||
retryAdminNinetyoneOrder,
|
|
||||||
runAdminCloudtentaclesFullFlow,
|
|
||||||
runAdminScheduledJobNow,
|
|
||||||
sendAdminCloudtentaclesSmsCode,
|
|
||||||
testAdminNotification,
|
|
||||||
testAdminCloudtentaclesLogin,
|
|
||||||
updateAdminKuaishouCloudFulfillmentConfig,
|
|
||||||
updateAdminKuaishouEticketSourceConfig,
|
|
||||||
updateAdminCloudtentaclesSourceConfig,
|
|
||||||
updateAdminNotificationConfig,
|
|
||||||
updateAdminScheduledJobsConfig,
|
|
||||||
updateAdminAgisoShopConfigs,
|
|
||||||
updateAdminFulfillmentBindingConfigs,
|
|
||||||
verifyAdminCloudtentaclesLoginCode,
|
|
||||||
validateAdminCloudtentaclesSession,
|
|
||||||
} from "../../services/admin/platform-config/service.js";
|
|
||||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
|
||||||
import type { AdminAgisoShopConfigSaveResponse } from "../../types/admin-write-models.js";
|
|
||||||
import type {
|
|
||||||
AdminAgisoShopConfigRouteBody,
|
|
||||||
AdminCloudtentaclesCatalogQueryRouteBody,
|
|
||||||
AdminCloudtentaclesFullFlowRouteBody,
|
|
||||||
AdminCloudtentaclesSendSmsCodeRouteBody,
|
|
||||||
AdminCloudtentaclesSkuBuyRouteBody,
|
|
||||||
AdminCloudtentaclesSkuUseRouteBody,
|
|
||||||
AdminCloudtentaclesSourceConfigRouteBody,
|
|
||||||
AdminCloudtentaclesTestLoginRouteBody,
|
|
||||||
AdminCloudtentaclesValidateSessionRouteBody,
|
|
||||||
AdminCloudtentaclesVirtualNumberRouteBody,
|
|
||||||
AdminEntityRouteParams,
|
|
||||||
AdminFulfillmentBindingConfigRouteBody,
|
|
||||||
AdminFulfillmentBindingLookupRouteBody,
|
|
||||||
AdminKuaishouCloudFulfillmentConfigRouteBody,
|
|
||||||
AdminKuaishouEticketConsumeRouteBody,
|
|
||||||
AdminKuaishouEticketDetailQueryRouteBody,
|
|
||||||
AdminKuaishouEticketShopInfoRouteBody,
|
|
||||||
AdminKuaishouEticketSourceConfigRouteBody,
|
|
||||||
AdminNotificationConfigRouteBody,
|
|
||||||
AdminNotificationTestRouteBody,
|
|
||||||
AdminScheduledJobsConfigRouteBody,
|
|
||||||
} from "../../types/admin-route-inputs.js";
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>;
|
|
||||||
|
|
||||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||||
|
router.use("/platform-config", agisoRouter);
|
||||||
router.get(
|
router.use("/platform-config", notificationsRouter);
|
||||||
"/platform-config/agiso-shops",
|
router.use("/platform-config", kuaishouEticketRouter);
|
||||||
createJsonHandler(() => getAdminAgisoShopConfigs(), {
|
router.use("/platform-config", ninetyoneRouter);
|
||||||
successMessage: "ok",
|
router.use("/platform-config", cloudtentaclesRouter);
|
||||||
errorMessage: "读取 Agiso 店铺配置失败",
|
router.use("/platform-config", fulfillmentBindingsRouter);
|
||||||
scope: "[admin/platform-config/agiso-shops]",
|
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/agiso-shops",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminAgisoShopConfigs(
|
|
||||||
req.body as AdminAgisoShopConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "Agiso 店铺配置已保存",
|
|
||||||
errorMessage: "保存 Agiso 店铺配置失败",
|
|
||||||
scope: "[admin/platform-config/agiso-shops]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as AdminAgisoShopConfigSaveResponse;
|
|
||||||
return {
|
|
||||||
action: "platform_shop_config_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "agiso_shops",
|
|
||||||
data: {
|
|
||||||
shopCount: result.shops.length,
|
|
||||||
filePath: result.filePath,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/notifications",
|
|
||||||
createJsonHandler(() => getAdminNotificationConfig(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取内部通知配置失败",
|
|
||||||
scope: "[admin/platform-config/notifications]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/notifications",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminNotificationConfig(
|
|
||||||
req.body as AdminNotificationConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "内部通知配置已保存",
|
|
||||||
errorMessage: "保存内部通知配置失败",
|
|
||||||
scope: "[admin/platform-config/notifications]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_notification_config_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "notifications",
|
|
||||||
data: {
|
|
||||||
filePath: String(result.filePath || "").trim(),
|
|
||||||
enabled: Boolean(result.source?.enabled),
|
|
||||||
barkRecipientCount: Array.isArray(
|
|
||||||
result.source?.channels?.bark?.recipients
|
|
||||||
)
|
|
||||||
? result.source.channels.bark.recipients.length
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/notifications/test",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
testAdminNotification(
|
|
||||||
req.body as AdminNotificationTestRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "内部通知测试已执行",
|
|
||||||
errorMessage: "内部通知测试失败",
|
|
||||||
scope: "[admin/platform-config/notifications/test]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_notification_test_sent",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "notifications",
|
|
||||||
data: {
|
|
||||||
channel: String(result.channel || "").trim(),
|
|
||||||
successCount: Number(result.successCount || 0),
|
|
||||||
failedCount: Number(result.failedCount || 0),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/scheduled-jobs",
|
|
||||||
createJsonHandler(() => getAdminScheduledJobsConfig(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取定时任务配置失败",
|
|
||||||
scope: "[admin/platform-config/scheduled-jobs]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/scheduled-jobs",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminScheduledJobsConfig(
|
|
||||||
req.body as AdminScheduledJobsConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "定时任务配置已保存",
|
|
||||||
errorMessage: "保存定时任务配置失败",
|
|
||||||
scope: "[admin/platform-config/scheduled-jobs]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_scheduled_jobs_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "scheduled_jobs",
|
|
||||||
data: {
|
|
||||||
filePath: String(result.filePath || "").trim(),
|
|
||||||
enabled: Boolean(result.source?.enabled),
|
|
||||||
jobCount: Array.isArray(result.source?.jobs)
|
|
||||||
? result.source.jobs.length
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/scheduled-jobs/:id/run",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
runAdminScheduledJobNow(
|
|
||||||
(req.params as AdminEntityRouteParams).id
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "定时任务已执行",
|
|
||||||
errorMessage: "执行定时任务失败",
|
|
||||||
scope: "[admin/platform-config/scheduled-jobs/:id/run]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_scheduled_job_run",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: String(
|
|
||||||
(req.params as AdminEntityRouteParams).id || ""
|
|
||||||
).trim(),
|
|
||||||
data: {
|
|
||||||
status: String(result.result?.status || "").trim(),
|
|
||||||
message: String(result.result?.message || "").trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/kuaishou-eticket-source",
|
|
||||||
createJsonHandler(() => getAdminKuaishouEticketSourceConfig(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取快手小店核销配置失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-eticket-source]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/kuaishou-eticket-source",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminKuaishouEticketSourceConfig(
|
|
||||||
req.body as AdminKuaishouEticketSourceConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "快手小店核销配置已保存",
|
|
||||||
errorMessage: "保存快手小店核销配置失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-eticket-source]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_kuaishou_eticket_source_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "kuaishou_eticket_source",
|
|
||||||
data: {
|
|
||||||
filePath: String(result.filePath || "").trim(),
|
|
||||||
enabled: Boolean(result.source?.enabled),
|
|
||||||
shopCount: Array.isArray(result.source?.shops)
|
|
||||||
? result.source.shops.length
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/ninetyone/orders",
|
|
||||||
createJsonHandler((req) => getAdminNinetyoneOrders(req.query), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 91卡券订单失败",
|
|
||||||
scope: "[admin/platform-config/ninetyone/orders]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/ninetyone/orders/:id/retry",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
retryAdminNinetyoneOrder(
|
|
||||||
(req.params as AdminEntityRouteParams).id
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "91卡券订单已重试",
|
|
||||||
errorMessage: "重试 91卡券订单失败",
|
|
||||||
scope: "[admin/platform-config/ninetyone/orders/:id/retry]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_ninetyone_order_retried",
|
|
||||||
targetType: "order",
|
|
||||||
targetId: String(result.orderId || "").trim(),
|
|
||||||
data: {
|
|
||||||
orderNo: String(result.orderNo || "").trim(),
|
|
||||||
taskCount: Number(result.taskCount || 0),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/ninetyone/orders/:id/fail",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
failAdminNinetyoneOrder(
|
|
||||||
(req.params as AdminEntityRouteParams).id,
|
|
||||||
req.body as { reason?: string }
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "91卡券订单已标记失败",
|
|
||||||
errorMessage: "标记 91卡券订单失败",
|
|
||||||
scope: "[admin/platform-config/ninetyone/orders/:id/fail]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_ninetyone_order_failed",
|
|
||||||
targetType: "order",
|
|
||||||
targetId: String(result.orderId || "").trim(),
|
|
||||||
data: {
|
|
||||||
orderNo: String(result.orderNo || "").trim(),
|
|
||||||
reason: String(
|
|
||||||
(req.body as { reason?: string }).reason || ""
|
|
||||||
).trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/kuaishou-eticket/query-detail",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
queryAdminKuaishouEticketDetail(
|
|
||||||
req.body as AdminKuaishouEticketDetailQueryRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "快手小店核销信息查询成功",
|
|
||||||
errorMessage: "查询快手小店核销信息失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-eticket/query-detail]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const body = req.body as AdminKuaishouEticketDetailQueryRouteBody;
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_kuaishou_eticket_query_detail",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId:
|
|
||||||
String(result.eTicketId || body.eTicketId || "").trim() ||
|
|
||||||
"kuaishou_eticket",
|
|
||||||
data: {
|
|
||||||
result: Number(result.result || 0),
|
|
||||||
ok: Boolean(result.ok),
|
|
||||||
alreadyConsumed: Boolean(result.alreadyConsumed),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/kuaishou-eticket/query-shop-info",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
queryAdminKuaishouEticketShopInfo(
|
|
||||||
req.body as AdminKuaishouEticketShopInfoRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "快手小店店铺信息查询成功",
|
|
||||||
errorMessage: "查询快手小店店铺信息失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-eticket/query-shop-info]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const body = req.body as AdminKuaishouEticketShopInfoRouteBody;
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_kuaishou_eticket_query_shop_info",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId:
|
|
||||||
String(result.shop?.shopId || body.shopId || "").trim() ||
|
|
||||||
"kuaishou_eticket_shop_info",
|
|
||||||
data: {
|
|
||||||
result: Number(result.result || 0),
|
|
||||||
ok: Boolean(result.ok),
|
|
||||||
kshopName: String(result.shop?.kshopName || "").trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/kuaishou-eticket/consume",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
consumeAdminKuaishouEticket(
|
|
||||||
req.body as AdminKuaishouEticketConsumeRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "快手小店核销请求已执行",
|
|
||||||
errorMessage: "执行快手小店核销失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-eticket/consume]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const body = req.body as AdminKuaishouEticketConsumeRouteBody;
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_kuaishou_eticket_consume",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId:
|
|
||||||
String(result.eTicketId || body.eTicketId || "").trim() ||
|
|
||||||
"kuaishou_eticket",
|
|
||||||
data: {
|
|
||||||
result: Number(result.result || 0),
|
|
||||||
consumed: Boolean(result.consumed),
|
|
||||||
alreadyConsumed: Boolean(result.alreadyConsumed),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/cloudtentacles-source",
|
|
||||||
createJsonHandler(() => listAdminCloudtentaclesSources(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 cloudtentacles 履约平台配置失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles-source]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.delete(
|
|
||||||
"/platform-config/cloudtentacles-source/:sourceKey",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
deleteAdminCloudtentaclesSource(
|
|
||||||
String(req.params.sourceKey || "").trim()
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 履约平台配置已删除",
|
|
||||||
errorMessage: "删除 cloudtentacles 履约平台配置失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles-source/:sourceKey]",
|
|
||||||
audit: (req) => ({
|
|
||||||
action: "platform_cloudtentacles_source_deleted",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: String(req.params.sourceKey || "").trim(),
|
|
||||||
data: {},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles-source",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminCloudtentaclesSourceConfig(
|
|
||||||
req.body as AdminCloudtentaclesSourceConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 履约平台配置已保存",
|
|
||||||
errorMessage: "保存 cloudtentacles 履约平台配置失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles-source]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_cloudtentacles_source_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "cloudtentacles_source",
|
|
||||||
data: {
|
|
||||||
filePath: String(result.filePath || "").trim(),
|
|
||||||
username: String(result.source?.username || "").trim(),
|
|
||||||
enabled: Boolean(result.source?.enabled),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/send-sms-code",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
sendAdminCloudtentaclesSmsCode(
|
|
||||||
req.body as AdminCloudtentaclesSendSmsCodeRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 短信验证码已发送",
|
|
||||||
errorMessage: "cloudtentacles 发送短信验证码失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/send-sms-code]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const body = req.body as AdminCloudtentaclesSendSmsCodeRouteBody;
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_cloudtentacles_send_sms_code",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId:
|
|
||||||
String(result.username || body.username || "").trim() ||
|
|
||||||
"cloudtentacles",
|
|
||||||
data: {
|
|
||||||
baseUrl: result.baseUrl || String(body.baseUrl || "").trim(),
|
|
||||||
phoneMasked: result.phoneMasked || "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/test-login",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
testAdminCloudtentaclesLogin(
|
|
||||||
req.body as AdminCloudtentaclesTestLoginRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 登录测试成功",
|
|
||||||
errorMessage: "cloudtentacles 登录测试失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/test-login]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const body = req.body as AdminCloudtentaclesTestLoginRouteBody;
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_cloudtentacles_test_login",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId:
|
|
||||||
String(result.username || body.username || "").trim() ||
|
|
||||||
"cloudtentacles",
|
|
||||||
data: {
|
|
||||||
baseUrl: result.baseUrl || String(body.baseUrl || "").trim(),
|
|
||||||
permissionCount: Number(result.session?.permissionCount || 0),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/validate-session",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
validateAdminCloudtentaclesSession(
|
|
||||||
req.body as AdminCloudtentaclesValidateSessionRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 会话校验成功",
|
|
||||||
errorMessage: "cloudtentacles 会话校验失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/validate-session]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_cloudtentacles_validate_session",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "cloudtentacles_session",
|
|
||||||
data: {
|
|
||||||
baseUrl: String(result.baseUrl || "").trim(),
|
|
||||||
permissionCount: Number(result.session?.permissionCount || 0),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/asset",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminCloudtentaclesAsset(
|
|
||||||
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 余额查询成功",
|
|
||||||
errorMessage: "cloudtentacles 余额查询失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/asset]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/categories",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminCloudtentaclesCategories(
|
|
||||||
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 分类查询成功",
|
|
||||||
errorMessage: "cloudtentacles 分类查询失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/categories]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/sku/list",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminCloudtentaclesSkuList(
|
|
||||||
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles SKU 列表查询成功",
|
|
||||||
errorMessage: "cloudtentacles SKU 列表查询失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/sku/list]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/sku/buy",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
buyAdminCloudtentaclesSku(
|
|
||||||
req.body as AdminCloudtentaclesSkuBuyRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles SKU 购买成功",
|
|
||||||
errorMessage: "cloudtentacles SKU 购买失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/sku/buy]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/sku/use",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
useAdminCloudtentaclesSku(
|
|
||||||
req.body as AdminCloudtentaclesSkuUseRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 发货成功",
|
|
||||||
errorMessage: "cloudtentacles 发货失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/sku/use]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/knapsack",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminCloudtentaclesKnapsack(
|
|
||||||
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 背包查询成功",
|
|
||||||
errorMessage: "cloudtentacles 背包查询失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/knapsack]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/list",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
listAdminCloudtentaclesVirtualNumbers(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 虚拟号列表查询成功",
|
|
||||||
errorMessage: "cloudtentacles 虚拟号列表查询失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/list]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/appoint",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
appointAdminCloudtentaclesVirtualNumber(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 虚拟号申请成功",
|
|
||||||
errorMessage: "cloudtentacles 虚拟号申请失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/appoint]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/generate-login-code",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
generateAdminCloudtentaclesLoginCode(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 登录码生成成功",
|
|
||||||
errorMessage: "cloudtentacles 登录码生成失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/generate-login-code]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/fetch-code",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
fetchAdminCloudtentaclesVirtualNumberCode(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 验证码获取成功",
|
|
||||||
errorMessage: "cloudtentacles 验证码获取失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/fetch-code]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/verify-code",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
verifyAdminCloudtentaclesLoginCode(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 登录码校验成功",
|
|
||||||
errorMessage: "cloudtentacles 登录码校验失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/verify-code]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/bind-url",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminCloudtentaclesBindUrl(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 兑换链接获取成功",
|
|
||||||
errorMessage: "cloudtentacles 兑换链接获取失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/bind-url]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/vn/back",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
backAdminCloudtentaclesVirtualNumber(
|
|
||||||
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 号码退还成功",
|
|
||||||
errorMessage: "cloudtentacles 号码退还失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/vn/back]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/cloudtentacles/debug/full-flow",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
runAdminCloudtentaclesFullFlow(
|
|
||||||
req.body as AdminCloudtentaclesFullFlowRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "cloudtentacles 完整调试流程执行成功",
|
|
||||||
errorMessage: "cloudtentacles 完整调试流程执行失败",
|
|
||||||
scope: "[admin/platform-config/cloudtentacles/debug/full-flow]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/fulfillment-bindings",
|
|
||||||
createJsonHandler(() => getAdminFulfillmentBindingConfigs(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取履约配置失败",
|
|
||||||
scope: "[admin/platform-config/fulfillment-bindings]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/fulfillment-bindings/lookup-order",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
lookupAdminFulfillmentBindingOrder(
|
|
||||||
req.body as AdminFulfillmentBindingLookupRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "订单商品查询成功",
|
|
||||||
errorMessage: "手动查询订单商品失败",
|
|
||||||
scope: "[admin/platform-config/fulfillment-bindings/lookup-order]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_fulfillment_order_lookup",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: [
|
|
||||||
result.order?.provider || "unknown",
|
|
||||||
result.order?.platform || "unknown",
|
|
||||||
result.order?.shopId || "unknown",
|
|
||||||
result.order?.platformOrderId || "unknown",
|
|
||||||
].join(":"),
|
|
||||||
data: {
|
|
||||||
itemCount: Array.isArray(result.items) ? result.items.length : 0,
|
|
||||||
shopId:
|
|
||||||
result.order?.shopId ||
|
|
||||||
String(
|
|
||||||
(req.body as AdminFulfillmentBindingLookupRouteBody)?.shopId || ""
|
|
||||||
).trim(),
|
|
||||||
platformOrderId:
|
|
||||||
result.order?.platformOrderId ||
|
|
||||||
String(
|
|
||||||
(req.body as AdminFulfillmentBindingLookupRouteBody)
|
|
||||||
?.platformOrderId || ""
|
|
||||||
).trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/fulfillment-bindings",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminFulfillmentBindingConfigs(
|
|
||||||
req.body as AdminFulfillmentBindingConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "履约配置已保存",
|
|
||||||
errorMessage: "保存履约配置失败",
|
|
||||||
scope: "[admin/platform-config/fulfillment-bindings]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_fulfillment_config_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "fulfillment_bindings",
|
|
||||||
data: {
|
|
||||||
bindingCount: Array.isArray(result.bindings)
|
|
||||||
? result.bindings.length
|
|
||||||
: 0,
|
|
||||||
filePath: result.filePath || "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/platform-config/kuaishou-cloud-fulfillment",
|
|
||||||
createJsonHandler(() => getAdminKuaishouCloudFulfillmentConfig(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取新履约配置失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/platform-config/kuaishou-cloud-fulfillment",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminKuaishouCloudFulfillmentConfig(
|
|
||||||
req.body as AdminKuaishouCloudFulfillmentConfigRouteBody
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "新履约配置已保存",
|
|
||||||
errorMessage: "保存新履约配置失败",
|
|
||||||
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as JsonRecord;
|
|
||||||
return {
|
|
||||||
action: "platform_kuaishou_cloud_fulfillment_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "kuaishou_cloud_fulfillment",
|
|
||||||
data: {
|
|
||||||
itemCount: Array.isArray(result.source?.items)
|
|
||||||
? result.source.items.length
|
|
||||||
: 0,
|
|
||||||
filePath: String(result.filePath || "").trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminAgisoShopConfigs,
|
||||||
|
updateAdminAgisoShopConfigs,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type { AdminAgisoShopConfigRouteBody } from "../../../types/admin-route-inputs.js";
|
||||||
|
import type { AdminAgisoShopConfigSaveResponse } from "../../../types/admin-write-models.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/agiso-shops",
|
||||||
|
createJsonHandler(() => getAdminAgisoShopConfigs(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取 Agiso 店铺配置失败",
|
||||||
|
scope: "[admin/platform-config/agiso-shops]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/agiso-shops",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminAgisoShopConfigs(req.body as AdminAgisoShopConfigRouteBody),
|
||||||
|
{
|
||||||
|
successMessage: "Agiso 店铺配置已保存",
|
||||||
|
errorMessage: "保存 Agiso 店铺配置失败",
|
||||||
|
scope: "[admin/platform-config/agiso-shops]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as AdminAgisoShopConfigSaveResponse;
|
||||||
|
return {
|
||||||
|
action: "platform_shop_config_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "agiso_shops",
|
||||||
|
data: {
|
||||||
|
shopCount: result.shops.length,
|
||||||
|
filePath: result.filePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
appointAdminCloudtentaclesVirtualNumber,
|
||||||
|
backAdminCloudtentaclesVirtualNumber,
|
||||||
|
buyAdminCloudtentaclesSku,
|
||||||
|
deleteAdminCloudtentaclesSource,
|
||||||
|
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||||
|
generateAdminCloudtentaclesLoginCode,
|
||||||
|
getAdminCloudtentaclesAsset,
|
||||||
|
getAdminCloudtentaclesBindUrl,
|
||||||
|
getAdminCloudtentaclesCategories,
|
||||||
|
getAdminCloudtentaclesKnapsack,
|
||||||
|
getAdminCloudtentaclesSkuList,
|
||||||
|
listAdminCloudtentaclesSources,
|
||||||
|
listAdminCloudtentaclesVirtualNumbers,
|
||||||
|
runAdminCloudtentaclesFullFlow,
|
||||||
|
sendAdminCloudtentaclesSmsCode,
|
||||||
|
testAdminCloudtentaclesLogin,
|
||||||
|
updateAdminCloudtentaclesSourceConfig,
|
||||||
|
useAdminCloudtentaclesSku,
|
||||||
|
validateAdminCloudtentaclesSession,
|
||||||
|
verifyAdminCloudtentaclesLoginCode,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type {
|
||||||
|
AdminCloudtentaclesCatalogQueryRouteBody,
|
||||||
|
AdminCloudtentaclesFullFlowRouteBody,
|
||||||
|
AdminCloudtentaclesSendSmsCodeRouteBody,
|
||||||
|
AdminCloudtentaclesSkuBuyRouteBody,
|
||||||
|
AdminCloudtentaclesSkuUseRouteBody,
|
||||||
|
AdminCloudtentaclesSourceConfigRouteBody,
|
||||||
|
AdminCloudtentaclesTestLoginRouteBody,
|
||||||
|
AdminCloudtentaclesValidateSessionRouteBody,
|
||||||
|
AdminCloudtentaclesVirtualNumberRouteBody,
|
||||||
|
} from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/cloudtentacles-source",
|
||||||
|
createJsonHandler(() => listAdminCloudtentaclesSources(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取 cloudtentacles 履约平台配置失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles-source]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/cloudtentacles-source/:sourceKey",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
deleteAdminCloudtentaclesSource(
|
||||||
|
String(req.params.sourceKey || "").trim()
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 履约平台配置已删除",
|
||||||
|
errorMessage: "删除 cloudtentacles 履约平台配置失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles-source/:sourceKey]",
|
||||||
|
audit: (req) => ({
|
||||||
|
action: "platform_cloudtentacles_source_deleted",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: String(req.params.sourceKey || "").trim(),
|
||||||
|
data: {},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles-source",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminCloudtentaclesSourceConfig(
|
||||||
|
req.body as AdminCloudtentaclesSourceConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 履约平台配置已保存",
|
||||||
|
errorMessage: "保存 cloudtentacles 履约平台配置失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles-source]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_cloudtentacles_source_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "cloudtentacles_source",
|
||||||
|
data: {
|
||||||
|
filePath: String(result.filePath || "").trim(),
|
||||||
|
username: String(result.source?.username || "").trim(),
|
||||||
|
enabled: Boolean(result.source?.enabled),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/send-sms-code",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
sendAdminCloudtentaclesSmsCode(
|
||||||
|
req.body as AdminCloudtentaclesSendSmsCodeRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 短信验证码已发送",
|
||||||
|
errorMessage: "cloudtentacles 发送短信验证码失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/send-sms-code]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const body = req.body as AdminCloudtentaclesSendSmsCodeRouteBody;
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_cloudtentacles_send_sms_code",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId:
|
||||||
|
String(result.username || body.username || "").trim() ||
|
||||||
|
"cloudtentacles",
|
||||||
|
data: {
|
||||||
|
baseUrl: result.baseUrl || String(body.baseUrl || "").trim(),
|
||||||
|
phoneMasked: result.phoneMasked || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/test-login",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
testAdminCloudtentaclesLogin(
|
||||||
|
req.body as AdminCloudtentaclesTestLoginRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 登录测试成功",
|
||||||
|
errorMessage: "cloudtentacles 登录测试失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/test-login]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const body = req.body as AdminCloudtentaclesTestLoginRouteBody;
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_cloudtentacles_test_login",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId:
|
||||||
|
String(result.username || body.username || "").trim() ||
|
||||||
|
"cloudtentacles",
|
||||||
|
data: {
|
||||||
|
baseUrl: result.baseUrl || String(body.baseUrl || "").trim(),
|
||||||
|
permissionCount: Number(result.session?.permissionCount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/validate-session",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
validateAdminCloudtentaclesSession(
|
||||||
|
req.body as AdminCloudtentaclesValidateSessionRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 会话校验成功",
|
||||||
|
errorMessage: "cloudtentacles 会话校验失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/validate-session]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_cloudtentacles_validate_session",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "cloudtentacles_session",
|
||||||
|
data: {
|
||||||
|
baseUrl: String(result.baseUrl || "").trim(),
|
||||||
|
permissionCount: Number(result.session?.permissionCount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/asset",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
getAdminCloudtentaclesAsset(
|
||||||
|
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 余额查询成功",
|
||||||
|
errorMessage: "cloudtentacles 余额查询失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/asset]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/categories",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
getAdminCloudtentaclesCategories(
|
||||||
|
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 分类查询成功",
|
||||||
|
errorMessage: "cloudtentacles 分类查询失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/categories]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/sku/list",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
getAdminCloudtentaclesSkuList(
|
||||||
|
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles SKU 列表查询成功",
|
||||||
|
errorMessage: "cloudtentacles SKU 列表查询失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/sku/list]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/sku/buy",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
buyAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuBuyRouteBody),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles SKU 购买成功",
|
||||||
|
errorMessage: "cloudtentacles SKU 购买失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/sku/buy]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/sku/use",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
useAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuUseRouteBody),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 发货成功",
|
||||||
|
errorMessage: "cloudtentacles 发货失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/sku/use]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/knapsack",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
getAdminCloudtentaclesKnapsack(
|
||||||
|
req.body as AdminCloudtentaclesCatalogQueryRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 背包查询成功",
|
||||||
|
errorMessage: "cloudtentacles 背包查询失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/knapsack]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/list",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
listAdminCloudtentaclesVirtualNumbers(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 虚拟号列表查询成功",
|
||||||
|
errorMessage: "cloudtentacles 虚拟号列表查询失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/list]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/appoint",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
appointAdminCloudtentaclesVirtualNumber(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 虚拟号申请成功",
|
||||||
|
errorMessage: "cloudtentacles 虚拟号申请失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/appoint]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/generate-login-code",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
generateAdminCloudtentaclesLoginCode(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 登录码生成成功",
|
||||||
|
errorMessage: "cloudtentacles 登录码生成失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/generate-login-code]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/fetch-code",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
fetchAdminCloudtentaclesVirtualNumberCode(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 验证码获取成功",
|
||||||
|
errorMessage: "cloudtentacles 验证码获取失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/fetch-code]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/verify-code",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
verifyAdminCloudtentaclesLoginCode(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 登录码校验成功",
|
||||||
|
errorMessage: "cloudtentacles 登录码校验失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/verify-code]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/bind-url",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
getAdminCloudtentaclesBindUrl(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 兑换链接获取成功",
|
||||||
|
errorMessage: "cloudtentacles 兑换链接获取失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/bind-url]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/vn/back",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
backAdminCloudtentaclesVirtualNumber(
|
||||||
|
req.body as AdminCloudtentaclesVirtualNumberRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 号码退还成功",
|
||||||
|
errorMessage: "cloudtentacles 号码退还失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/vn/back]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/cloudtentacles/debug/full-flow",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
runAdminCloudtentaclesFullFlow(
|
||||||
|
req.body as AdminCloudtentaclesFullFlowRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "cloudtentacles 完整调试流程执行成功",
|
||||||
|
errorMessage: "cloudtentacles 完整调试流程执行失败",
|
||||||
|
scope: "[admin/platform-config/cloudtentacles/debug/full-flow]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminFulfillmentBindingConfigs,
|
||||||
|
lookupAdminFulfillmentBindingOrder,
|
||||||
|
updateAdminFulfillmentBindingConfigs,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type {
|
||||||
|
AdminFulfillmentBindingConfigRouteBody,
|
||||||
|
AdminFulfillmentBindingLookupRouteBody,
|
||||||
|
} from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/fulfillment-bindings",
|
||||||
|
createJsonHandler(() => getAdminFulfillmentBindingConfigs(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取履约配置失败",
|
||||||
|
scope: "[admin/platform-config/fulfillment-bindings]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/fulfillment-bindings/lookup-order",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
lookupAdminFulfillmentBindingOrder(
|
||||||
|
req.body as AdminFulfillmentBindingLookupRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "订单商品查询成功",
|
||||||
|
errorMessage: "手动查询订单商品失败",
|
||||||
|
scope: "[admin/platform-config/fulfillment-bindings/lookup-order]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_fulfillment_order_lookup",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: [
|
||||||
|
result.order?.provider || "unknown",
|
||||||
|
result.order?.platform || "unknown",
|
||||||
|
result.order?.shopId || "unknown",
|
||||||
|
result.order?.platformOrderId || "unknown",
|
||||||
|
].join(":"),
|
||||||
|
data: {
|
||||||
|
itemCount: Array.isArray(result.items) ? result.items.length : 0,
|
||||||
|
shopId:
|
||||||
|
result.order?.shopId ||
|
||||||
|
String(
|
||||||
|
(req.body as AdminFulfillmentBindingLookupRouteBody)?.shopId ||
|
||||||
|
""
|
||||||
|
).trim(),
|
||||||
|
platformOrderId:
|
||||||
|
result.order?.platformOrderId ||
|
||||||
|
String(
|
||||||
|
(req.body as AdminFulfillmentBindingLookupRouteBody)
|
||||||
|
?.platformOrderId || ""
|
||||||
|
).trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/fulfillment-bindings",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminFulfillmentBindingConfigs(
|
||||||
|
req.body as AdminFulfillmentBindingConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "履约配置已保存",
|
||||||
|
errorMessage: "保存履约配置失败",
|
||||||
|
scope: "[admin/platform-config/fulfillment-bindings]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_fulfillment_config_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "fulfillment_bindings",
|
||||||
|
data: {
|
||||||
|
bindingCount: Array.isArray(result.bindings)
|
||||||
|
? result.bindings.length
|
||||||
|
: 0,
|
||||||
|
filePath: result.filePath || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminKuaishouCloudFulfillmentConfig,
|
||||||
|
updateAdminKuaishouCloudFulfillmentConfig,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/kuaishou-cloud-fulfillment",
|
||||||
|
createJsonHandler(() => getAdminKuaishouCloudFulfillmentConfig(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取新履约配置失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/kuaishou-cloud-fulfillment",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminKuaishouCloudFulfillmentConfig(
|
||||||
|
req.body as AdminKuaishouCloudFulfillmentConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "新履约配置已保存",
|
||||||
|
errorMessage: "保存新履约配置失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_kuaishou_cloud_fulfillment_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "kuaishou_cloud_fulfillment",
|
||||||
|
data: {
|
||||||
|
itemCount: Array.isArray(result.source?.items)
|
||||||
|
? result.source.items.length
|
||||||
|
: 0,
|
||||||
|
filePath: String(result.filePath || "").trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
consumeAdminKuaishouEticket,
|
||||||
|
getAdminKuaishouEticketSourceConfig,
|
||||||
|
queryAdminKuaishouEticketDetail,
|
||||||
|
queryAdminKuaishouEticketShopInfo,
|
||||||
|
updateAdminKuaishouEticketSourceConfig,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type {
|
||||||
|
AdminKuaishouEticketConsumeRouteBody,
|
||||||
|
AdminKuaishouEticketDetailQueryRouteBody,
|
||||||
|
AdminKuaishouEticketShopInfoRouteBody,
|
||||||
|
AdminKuaishouEticketSourceConfigRouteBody,
|
||||||
|
} from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/kuaishou-eticket-source",
|
||||||
|
createJsonHandler(() => getAdminKuaishouEticketSourceConfig(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取快手小店核销配置失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-eticket-source]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/kuaishou-eticket-source",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminKuaishouEticketSourceConfig(
|
||||||
|
req.body as AdminKuaishouEticketSourceConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "快手小店核销配置已保存",
|
||||||
|
errorMessage: "保存快手小店核销配置失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-eticket-source]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_kuaishou_eticket_source_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "kuaishou_eticket_source",
|
||||||
|
data: {
|
||||||
|
filePath: String(result.filePath || "").trim(),
|
||||||
|
enabled: Boolean(result.source?.enabled),
|
||||||
|
shopCount: Array.isArray(result.source?.shops)
|
||||||
|
? result.source.shops.length
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/kuaishou-eticket/query-detail",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
queryAdminKuaishouEticketDetail(
|
||||||
|
req.body as AdminKuaishouEticketDetailQueryRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "快手小店核销信息查询成功",
|
||||||
|
errorMessage: "查询快手小店核销信息失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-eticket/query-detail]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const body = req.body as AdminKuaishouEticketDetailQueryRouteBody;
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_kuaishou_eticket_query_detail",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId:
|
||||||
|
String(result.eTicketId || body.eTicketId || "").trim() ||
|
||||||
|
"kuaishou_eticket",
|
||||||
|
data: {
|
||||||
|
result: Number(result.result || 0),
|
||||||
|
ok: Boolean(result.ok),
|
||||||
|
alreadyConsumed: Boolean(result.alreadyConsumed),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/kuaishou-eticket/query-shop-info",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
queryAdminKuaishouEticketShopInfo(
|
||||||
|
req.body as AdminKuaishouEticketShopInfoRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "快手小店店铺信息查询成功",
|
||||||
|
errorMessage: "查询快手小店店铺信息失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-eticket/query-shop-info]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const body = req.body as AdminKuaishouEticketShopInfoRouteBody;
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_kuaishou_eticket_query_shop_info",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId:
|
||||||
|
String(result.shop?.shopId || body.shopId || "").trim() ||
|
||||||
|
"kuaishou_eticket_shop_info",
|
||||||
|
data: {
|
||||||
|
result: Number(result.result || 0),
|
||||||
|
ok: Boolean(result.ok),
|
||||||
|
kshopName: String(result.shop?.kshopName || "").trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/kuaishou-eticket/consume",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
consumeAdminKuaishouEticket(
|
||||||
|
req.body as AdminKuaishouEticketConsumeRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "快手小店核销请求已执行",
|
||||||
|
errorMessage: "执行快手小店核销失败",
|
||||||
|
scope: "[admin/platform-config/kuaishou-eticket/consume]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const body = req.body as AdminKuaishouEticketConsumeRouteBody;
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_kuaishou_eticket_consume",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId:
|
||||||
|
String(result.eTicketId || body.eTicketId || "").trim() ||
|
||||||
|
"kuaishou_eticket",
|
||||||
|
data: {
|
||||||
|
result: Number(result.result || 0),
|
||||||
|
consumed: Boolean(result.consumed),
|
||||||
|
alreadyConsumed: Boolean(result.alreadyConsumed),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
failAdminNinetyoneOrder,
|
||||||
|
getAdminNinetyoneOrders,
|
||||||
|
retryAdminNinetyoneOrder,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/ninetyone/orders",
|
||||||
|
createJsonHandler((req) => getAdminNinetyoneOrders(req.query), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取 91卡券订单失败",
|
||||||
|
scope: "[admin/platform-config/ninetyone/orders]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/ninetyone/orders/:id/retry",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => retryAdminNinetyoneOrder((req.params as AdminEntityRouteParams).id),
|
||||||
|
{
|
||||||
|
successMessage: "91卡券订单已重试",
|
||||||
|
errorMessage: "重试 91卡券订单失败",
|
||||||
|
scope: "[admin/platform-config/ninetyone/orders/:id/retry]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_ninetyone_order_retried",
|
||||||
|
targetType: "order",
|
||||||
|
targetId: String(result.orderId || "").trim(),
|
||||||
|
data: {
|
||||||
|
orderNo: String(result.orderNo || "").trim(),
|
||||||
|
taskCount: Number(result.taskCount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/ninetyone/orders/:id/fail",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
failAdminNinetyoneOrder(
|
||||||
|
(req.params as AdminEntityRouteParams).id,
|
||||||
|
req.body as { reason?: string }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "91卡券订单已标记失败",
|
||||||
|
errorMessage: "标记 91卡券订单失败",
|
||||||
|
scope: "[admin/platform-config/ninetyone/orders/:id/fail]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_ninetyone_order_failed",
|
||||||
|
targetType: "order",
|
||||||
|
targetId: String(result.orderId || "").trim(),
|
||||||
|
data: {
|
||||||
|
orderNo: String(result.orderNo || "").trim(),
|
||||||
|
reason: String(
|
||||||
|
(req.body as { reason?: string }).reason || ""
|
||||||
|
).trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminNotificationConfig,
|
||||||
|
getAdminScheduledJobsConfig,
|
||||||
|
runAdminScheduledJobNow,
|
||||||
|
testAdminNotification,
|
||||||
|
updateAdminNotificationConfig,
|
||||||
|
updateAdminScheduledJobsConfig,
|
||||||
|
} from "../../../services/admin/platform-config/service.js";
|
||||||
|
import type {
|
||||||
|
AdminEntityRouteParams,
|
||||||
|
AdminNotificationConfigRouteBody,
|
||||||
|
AdminNotificationTestRouteBody,
|
||||||
|
AdminScheduledJobsConfigRouteBody,
|
||||||
|
} from "../../../types/admin-route-inputs.js";
|
||||||
|
import { createJsonHandler } from "../shared.js";
|
||||||
|
import type { JsonRecord } from "./shared.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/notifications",
|
||||||
|
createJsonHandler(() => getAdminNotificationConfig(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取内部通知配置失败",
|
||||||
|
scope: "[admin/platform-config/notifications]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/notifications",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminNotificationConfig(
|
||||||
|
req.body as AdminNotificationConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "内部通知配置已保存",
|
||||||
|
errorMessage: "保存内部通知配置失败",
|
||||||
|
scope: "[admin/platform-config/notifications]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_notification_config_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "notifications",
|
||||||
|
data: {
|
||||||
|
filePath: String(result.filePath || "").trim(),
|
||||||
|
enabled: Boolean(result.source?.enabled),
|
||||||
|
barkRecipientCount: Array.isArray(
|
||||||
|
result.source?.channels?.bark?.recipients
|
||||||
|
)
|
||||||
|
? result.source.channels.bark.recipients.length
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/notifications/test",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => testAdminNotification(req.body as AdminNotificationTestRouteBody),
|
||||||
|
{
|
||||||
|
successMessage: "内部通知测试已执行",
|
||||||
|
errorMessage: "内部通知测试失败",
|
||||||
|
scope: "[admin/platform-config/notifications/test]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_notification_test_sent",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "notifications",
|
||||||
|
data: {
|
||||||
|
channel: String(result.channel || "").trim(),
|
||||||
|
successCount: Number(result.successCount || 0),
|
||||||
|
failedCount: Number(result.failedCount || 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/scheduled-jobs",
|
||||||
|
createJsonHandler(() => getAdminScheduledJobsConfig(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取定时任务配置失败",
|
||||||
|
scope: "[admin/platform-config/scheduled-jobs]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/scheduled-jobs",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
updateAdminScheduledJobsConfig(
|
||||||
|
req.body as AdminScheduledJobsConfigRouteBody
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: "定时任务配置已保存",
|
||||||
|
errorMessage: "保存定时任务配置失败",
|
||||||
|
scope: "[admin/platform-config/scheduled-jobs]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_scheduled_jobs_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "scheduled_jobs",
|
||||||
|
data: {
|
||||||
|
filePath: String(result.filePath || "").trim(),
|
||||||
|
enabled: Boolean(result.source?.enabled),
|
||||||
|
jobCount: Array.isArray(result.source?.jobs)
|
||||||
|
? result.source.jobs.length
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/scheduled-jobs/:id/run",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => runAdminScheduledJobNow((req.params as AdminEntityRouteParams).id),
|
||||||
|
{
|
||||||
|
successMessage: "定时任务已执行",
|
||||||
|
errorMessage: "执行定时任务失败",
|
||||||
|
scope: "[admin/platform-config/scheduled-jobs/:id/run]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
return {
|
||||||
|
action: "platform_scheduled_job_run",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: String(
|
||||||
|
(req.params as AdminEntityRouteParams).id || ""
|
||||||
|
).trim(),
|
||||||
|
data: {
|
||||||
|
status: String(result.result?.status || "").trim(),
|
||||||
|
message: String(result.result?.message || "").trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type JsonRecord = Record<string, any>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
|||||||
|
import { createHttpError } from "../../../utils/http.js";
|
||||||
|
import { normalizeProductName } from "../../order/product-match-service.js";
|
||||||
|
import {
|
||||||
|
appointCloudtentaclesVirtualNumber,
|
||||||
|
backCloudtentaclesVirtualNumber,
|
||||||
|
fetchCloudtentaclesVirtualNumberCode,
|
||||||
|
generateCloudtentaclesLoginCode,
|
||||||
|
getCloudtentaclesBindUrl,
|
||||||
|
verifyCloudtentaclesLoginCode,
|
||||||
|
} from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||||
|
import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js";
|
||||||
|
|
||||||
|
export function resolveKuaishouCloudBindingResources(
|
||||||
|
flow,
|
||||||
|
{ skuItems = [], knapsackItems = [] } = {}
|
||||||
|
) {
|
||||||
|
const normalizedSkuItems = Array.isArray(skuItems)
|
||||||
|
? skuItems.filter(isCloudSkuLikeItem)
|
||||||
|
: [];
|
||||||
|
const normalizedKnapsackItems = Array.isArray(knapsackItems)
|
||||||
|
? knapsackItems.filter(isCloudSkuLikeItem)
|
||||||
|
: [];
|
||||||
|
const currentSkuId = Number(flow?.binding?.skuId || 0) || 0;
|
||||||
|
const currentSkuName = String(flow?.binding?.skuName || "").trim();
|
||||||
|
const nameCandidates = collectKuaishouCloudNameCandidates(flow);
|
||||||
|
|
||||||
|
const skuItemById =
|
||||||
|
currentSkuId > 0
|
||||||
|
? normalizedSkuItems.find(
|
||||||
|
(item) => Number(item.id || 0) === currentSkuId
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
const knapsackItemById =
|
||||||
|
currentSkuId > 0
|
||||||
|
? normalizedKnapsackItems.find(
|
||||||
|
(item) => Number(item.id || 0) === currentSkuId
|
||||||
|
) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (skuItemById || knapsackItemById) {
|
||||||
|
const matchedItem = skuItemById || knapsackItemById;
|
||||||
|
return {
|
||||||
|
skuId: Number(matchedItem?.id || 0) || 0,
|
||||||
|
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
|
||||||
|
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||||
|
skuItem: skuItemById,
|
||||||
|
knapsackItem: knapsackItemById,
|
||||||
|
resolvedByName: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedSkuItem = findCloudItemByNames(
|
||||||
|
normalizedSkuItems,
|
||||||
|
nameCandidates
|
||||||
|
);
|
||||||
|
const matchedKnapsackItem = findCloudItemByNames(
|
||||||
|
normalizedKnapsackItems,
|
||||||
|
nameCandidates,
|
||||||
|
matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0
|
||||||
|
);
|
||||||
|
const matchedItem = matchedSkuItem || matchedKnapsackItem;
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuId: Number(matchedItem?.id || 0) || 0,
|
||||||
|
skuName: String(currentSkuName || matchedItem?.name || "").trim(),
|
||||||
|
vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||||
|
skuItem: matchedSkuItem,
|
||||||
|
knapsackItem: matchedKnapsackItem,
|
||||||
|
resolvedByName: Boolean(matchedItem),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKuaishouCloudVnKeyCandidates(_input = {}) {
|
||||||
|
return [KUAISHOU_CLOUD_FIXED_VN_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) {
|
||||||
|
const { cloudContext = {}, vnKeyCandidates = [] } = input;
|
||||||
|
const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [];
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (const vnKey of candidates) {
|
||||||
|
let vnId = 0;
|
||||||
|
let vnPhone = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointed = await appointCloudtentaclesVirtualNumber({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
});
|
||||||
|
vnId = Number(appointed.item?.id || 0);
|
||||||
|
vnPhone = String(appointed.item?.phone || "").trim();
|
||||||
|
|
||||||
|
if (!vnId || !vnPhone) {
|
||||||
|
throw createHttpError("申请虚拟号成功但返回数据不完整", {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: "kuaishou_cloud_invalid_vn",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateCloudtentaclesLoginCode({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
id: vnId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
phone: vnPhone,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifyCloudtentaclesLoginCode({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
id: vnId,
|
||||||
|
code: fetchedCode.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindUrlResult = await getCloudtentaclesBindUrl({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
id: vnId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
vnKey,
|
||||||
|
vnId,
|
||||||
|
vnPhone,
|
||||||
|
bindUrl: String(bindUrlResult.bindUrl || "").trim(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
if (vnId > 0) {
|
||||||
|
try {
|
||||||
|
await backCloudtentaclesVirtualNumber({
|
||||||
|
...cloudContext,
|
||||||
|
key: vnKey,
|
||||||
|
id: vnId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 退号失败保留主错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecoverableKuaishouCloudVnKeyError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (
|
||||||
|
lastError ||
|
||||||
|
createHttpError("没有找到可用的 VN Key", {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_missing_binding_config",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectKuaishouCloudNameCandidates(flow) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
String(flow?.binding?.skuName || "").trim(),
|
||||||
|
String(flow?.internalSkuName || "").trim(),
|
||||||
|
String(flow?.internalSkuCode || "").trim(),
|
||||||
|
].filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCloudItemByNames(items, nameCandidates, preferredId = 0) {
|
||||||
|
const normalizedItems = Array.isArray(items) ? items : [];
|
||||||
|
const normalizedNames = nameCandidates
|
||||||
|
.map((item) => ({
|
||||||
|
raw: String(item || "").trim(),
|
||||||
|
normalized: normalizeProductName(item),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.raw && item.normalized);
|
||||||
|
|
||||||
|
if (normalizedNames.length === 0 || normalizedItems.length === 0) {
|
||||||
|
return preferredId > 0
|
||||||
|
? normalizedItems.find((item) => Number(item.id || 0) === preferredId) ||
|
||||||
|
null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredId > 0) {
|
||||||
|
const preferred =
|
||||||
|
normalizedItems.find((item) => Number(item.id || 0) === preferredId) ||
|
||||||
|
null;
|
||||||
|
if (preferred) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactMatches = normalizedItems.filter((item) => {
|
||||||
|
const itemName = normalizeProductName(item.name);
|
||||||
|
return normalizedNames.some(
|
||||||
|
(candidate) => candidate.normalized === itemName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (exactMatches.length > 0) {
|
||||||
|
return exactMatches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialMatches = normalizedItems.filter((item) => {
|
||||||
|
const itemName = normalizeProductName(item.name);
|
||||||
|
return normalizedNames.some(
|
||||||
|
(candidate) =>
|
||||||
|
itemName.includes(candidate.normalized) ||
|
||||||
|
candidate.normalized.includes(itemName)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (partialMatches.length > 0) {
|
||||||
|
return partialMatches.sort(
|
||||||
|
(left, right) =>
|
||||||
|
String(left.name || "").length - String(right.name || "").length
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloudSkuLikeItem(item) {
|
||||||
|
return Boolean(item) && typeof item === "object" && Number(item.id || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableKuaishouCloudVnKeyError(error) {
|
||||||
|
const errorCode = String(error?.errorCode || error?.code || "").trim();
|
||||||
|
const errorMessage = String(error?.message || "").trim();
|
||||||
|
return (
|
||||||
|
errorCode === "cloudtentacles_vn_bind_url_failed" &&
|
||||||
|
errorMessage.includes("不支持的游戏类型")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createHttpError } from "../../../utils/http.js";
|
||||||
|
import {
|
||||||
|
getCloudtentaclesSourceConfig,
|
||||||
|
getCloudtentaclesSourceByKey,
|
||||||
|
} from "../../platforms/cloudtentacles/source-config-service.js";
|
||||||
|
import {
|
||||||
|
getCloudtentaclesSessionState,
|
||||||
|
getCloudtentaclesSessionStateByKey,
|
||||||
|
} from "../../platforms/cloudtentacles/session-state-service.js";
|
||||||
|
import { normalizeStringArray } from "./domain.js";
|
||||||
|
|
||||||
|
export function resolvePersistedCloudtentaclesContext(sourceKey = "default") {
|
||||||
|
const source =
|
||||||
|
getCloudtentaclesSourceByKey(sourceKey) || getCloudtentaclesSourceConfig();
|
||||||
|
const session =
|
||||||
|
getCloudtentaclesSessionStateByKey(sourceKey) ||
|
||||||
|
getCloudtentaclesSessionState();
|
||||||
|
const token = String(session.token || "").trim();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createHttpError(
|
||||||
|
"当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_missing_cloud_token",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl:
|
||||||
|
String(session.baseUrl || source.baseUrl || "").trim() ||
|
||||||
|
"https://123.207.217.176",
|
||||||
|
token,
|
||||||
|
deviceId: String(session.deviceId || source.deviceId || "-").trim() || "-",
|
||||||
|
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按优先级依次尝试 cloudSourceKey 和 fallbacks 列表中的账号,
|
||||||
|
* 找到第一个有可用 token 的账号返回其 context。
|
||||||
|
* 将实际使用的 resolvedSourceKey 也返回,确保后续操作(退号、发货等)
|
||||||
|
* 使用同一个 sourceKey,防止跨账号操作导致数据不一致。
|
||||||
|
*/
|
||||||
|
export function resolvePersistedCloudtentaclesContextWithFallback(
|
||||||
|
primarySourceKey = "default",
|
||||||
|
fallbacks = []
|
||||||
|
) {
|
||||||
|
const candidates = [
|
||||||
|
String(primarySourceKey || "default").trim() || "default",
|
||||||
|
...normalizeStringArray(fallbacks),
|
||||||
|
];
|
||||||
|
|
||||||
|
const uniqueCandidates = [...new Set(candidates)];
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (const sourceKey of uniqueCandidates) {
|
||||||
|
const source = getCloudtentaclesSourceByKey(sourceKey);
|
||||||
|
const session = getCloudtentaclesSessionStateByKey(sourceKey);
|
||||||
|
|
||||||
|
if (!source) continue;
|
||||||
|
|
||||||
|
const token = String(session?.token || "").trim();
|
||||||
|
if (!token) {
|
||||||
|
lastError = createHttpError(
|
||||||
|
`cloudtentacles 账号 ${sourceKey} 没有可用 token`,
|
||||||
|
{ statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" }
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl:
|
||||||
|
String(session.baseUrl || source.baseUrl || "").trim() ||
|
||||||
|
"https://123.207.217.176",
|
||||||
|
token,
|
||||||
|
deviceId:
|
||||||
|
String(session.deviceId || source.deviceId || "-").trim() || "-",
|
||||||
|
deviceType: Number(session.deviceType ?? source.deviceType ?? 0),
|
||||||
|
resolvedSourceKey: sourceKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw (
|
||||||
|
lastError ||
|
||||||
|
createHttpError(
|
||||||
|
"所有 cloudtentacles 备选账号均不可用,请先到平台配置完成登录校验",
|
||||||
|
{ statusCode: 409, errorCode: "kuaishou_cloud_all_source_keys_exhausted" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js";
|
||||||
|
|
||||||
|
export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1";
|
||||||
|
|
||||||
|
export type JsonObject = Record<string, any>;
|
||||||
|
|
||||||
|
export function isKuaishouCloudTask(task) {
|
||||||
|
return String(task?.executor_key || "").trim() === "kuaishou_ct_assisted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKuaishouCloudFlow(value) {
|
||||||
|
const source = value && typeof value === "object" ? value : {};
|
||||||
|
const binding =
|
||||||
|
source.binding && typeof source.binding === "object" ? source.binding : {};
|
||||||
|
const role =
|
||||||
|
source.role && typeof source.role === "object" ? source.role : {};
|
||||||
|
const purchase =
|
||||||
|
source.purchase && typeof source.purchase === "object"
|
||||||
|
? source.purchase
|
||||||
|
: {};
|
||||||
|
const dispatch =
|
||||||
|
source.dispatch && typeof source.dispatch === "object"
|
||||||
|
? source.dispatch
|
||||||
|
: {};
|
||||||
|
const returnNumber =
|
||||||
|
source.returnNumber && typeof source.returnNumber === "object"
|
||||||
|
? source.returnNumber
|
||||||
|
: {};
|
||||||
|
const consume =
|
||||||
|
source.consume && typeof source.consume === "object" ? source.consume : {};
|
||||||
|
const ticket =
|
||||||
|
source.ticket && typeof source.ticket === "object" ? source.ticket : {};
|
||||||
|
|
||||||
|
const roleName = String(role.name || binding.roleName || "").trim();
|
||||||
|
const roleId = String(role.rid || binding.roleId || "").trim();
|
||||||
|
const bindPreparedAt = binding.bindPreparedAt || null;
|
||||||
|
const bindExpiresAt =
|
||||||
|
binding.bindExpiresAt ||
|
||||||
|
resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
configId: String(source.configId || "").trim(),
|
||||||
|
internalSkuCode: String(source.internalSkuCode || "").trim(),
|
||||||
|
internalSkuName: String(source.internalSkuName || "").trim(),
|
||||||
|
ticket: {
|
||||||
|
code: String(ticket.code || "").trim(),
|
||||||
|
status: String(ticket.status || "pending").trim() || "pending",
|
||||||
|
capturedAt: ticket.capturedAt || null,
|
||||||
|
capturedBy: ticket.capturedBy || null,
|
||||||
|
verifiedAt: ticket.verifiedAt || null,
|
||||||
|
oid: String(ticket.oid || "").trim(),
|
||||||
|
formToken: String(ticket.formToken || "").trim(),
|
||||||
|
leftCount: Number(ticket.leftCount || 0) || 0,
|
||||||
|
goodsTitle: String(ticket.goodsTitle || "").trim(),
|
||||||
|
},
|
||||||
|
binding: {
|
||||||
|
prepareStatus:
|
||||||
|
String(binding.prepareStatus || "pending").trim() || "pending",
|
||||||
|
cloudSourceKey:
|
||||||
|
String(binding.cloudSourceKey || "default").trim() || "default",
|
||||||
|
cloudSourceKeyFallbacks: normalizeStringArray(
|
||||||
|
binding.cloudSourceKeyFallbacks
|
||||||
|
),
|
||||||
|
resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(),
|
||||||
|
skuId: Number(binding.skuId || 0) || 0,
|
||||||
|
skuName: String(binding.skuName || "").trim(),
|
||||||
|
vnKey:
|
||||||
|
String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() ||
|
||||||
|
KUAISHOU_CLOUD_FIXED_VN_KEY,
|
||||||
|
vnId: Number(binding.vnId || 0) || 0,
|
||||||
|
vnPhone: String(binding.vnPhone || "").trim(),
|
||||||
|
bindUrl: String(binding.bindUrl || "").trim(),
|
||||||
|
bindPreparedAt,
|
||||||
|
bindExpiresAt,
|
||||||
|
bindProbeAt: binding.bindProbeAt || null,
|
||||||
|
bindProbeStatus: String(binding.bindProbeStatus || "").trim(),
|
||||||
|
bindProbeMessage: String(binding.bindProbeMessage || "").trim(),
|
||||||
|
roleName,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
status:
|
||||||
|
String(
|
||||||
|
role.status || (roleName || roleId ? "ready" : "pending")
|
||||||
|
).trim() || "pending",
|
||||||
|
name: roleName,
|
||||||
|
rid: roleId,
|
||||||
|
refreshedAt: role.refreshedAt || null,
|
||||||
|
errorMessage: String(role.errorMessage || "").trim(),
|
||||||
|
rawInfo:
|
||||||
|
role.rawInfo && typeof role.rawInfo === "object" ? role.rawInfo : null,
|
||||||
|
},
|
||||||
|
purchase: {
|
||||||
|
autoBuyEnabled: purchase.autoBuyEnabled !== false,
|
||||||
|
minAssetReserve: Number(purchase.minAssetReserve || 0) || 0,
|
||||||
|
usedKnapsack: purchase.usedKnapsack === true,
|
||||||
|
purchaseTriggered: purchase.purchaseTriggered === true,
|
||||||
|
assetBefore: Number(purchase.assetBefore || 0) || 0,
|
||||||
|
assetAfter: Number(purchase.assetAfter || 0) || 0,
|
||||||
|
purchaseAt: purchase.purchaseAt || null,
|
||||||
|
},
|
||||||
|
dispatch: {
|
||||||
|
status: String(dispatch.status || "pending").trim() || "pending",
|
||||||
|
dispatchAt: dispatch.dispatchAt || null,
|
||||||
|
dispatchBy: dispatch.dispatchBy || null,
|
||||||
|
sendType: Number(dispatch.sendType || 0) || 0,
|
||||||
|
note: String(dispatch.note || "").trim(),
|
||||||
|
},
|
||||||
|
returnNumber: {
|
||||||
|
status: String(returnNumber.status || "pending").trim() || "pending",
|
||||||
|
returnedAt: returnNumber.returnedAt || null,
|
||||||
|
returnedBy: returnNumber.returnedBy || null,
|
||||||
|
autoReturnEnabled: returnNumber.autoReturnEnabled === true,
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
status: String(consume.status || "pending").trim() || "pending",
|
||||||
|
shopId: String(consume.shopId || "").trim(),
|
||||||
|
shopName: String(consume.shopName || "").trim(),
|
||||||
|
autoConsumeEnabled: consume.autoConsumeEnabled === true,
|
||||||
|
consumedAt: consume.consumedAt || null,
|
||||||
|
errorMessage: String(consume.errorMessage || "").trim(),
|
||||||
|
},
|
||||||
|
notes: String(source.notes || "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKuaishouCloudRoleInfo(value) {
|
||||||
|
const rawInfo = value && typeof value === "object" ? value : null;
|
||||||
|
const nestedBindInfo =
|
||||||
|
rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object"
|
||||||
|
? rawInfo.sBindInfo
|
||||||
|
: null;
|
||||||
|
const source = nestedBindInfo || rawInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: String(
|
||||||
|
source?.name ||
|
||||||
|
source?.roleName ||
|
||||||
|
source?.nickname ||
|
||||||
|
source?.sRoleName ||
|
||||||
|
""
|
||||||
|
).trim(),
|
||||||
|
rid: String(
|
||||||
|
source?.rid ||
|
||||||
|
source?.roleId ||
|
||||||
|
source?.uid ||
|
||||||
|
source?.sRoleId ||
|
||||||
|
source?.sUserId ||
|
||||||
|
""
|
||||||
|
).trim(),
|
||||||
|
rawInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskPhone(value) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length <= 7) {
|
||||||
|
return `${text.slice(0, 2)}***${text.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, 3)}****${text.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) {
|
||||||
|
const preparedTime = Date.parse(String(preparedAt || ""));
|
||||||
|
if (!Number.isFinite(preparedTime)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = resolveCloudtentaclesConfig();
|
||||||
|
const ttlSeconds = Number(config.bindUrlTtlSeconds || 600);
|
||||||
|
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) {
|
||||||
|
const normalizedFlow = normalizeKuaishouCloudFlow(flow);
|
||||||
|
if (!normalizedFlow.binding.bindUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = normalizedFlow.binding.bindExpiresAt;
|
||||||
|
if (!expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresTime = Date.parse(String(expiresAt || ""));
|
||||||
|
if (!Number.isFinite(expiresTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiresTime > now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStringArray(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((v) => String(v || "").trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export function normalizeActor(actor) {
|
||||||
|
if (!actor || typeof actor !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = String(actor.source || "").trim();
|
||||||
|
const userId = Number(actor.userId || 0) || 0;
|
||||||
|
const username = String(actor.username || "").trim();
|
||||||
|
const role = String(actor.role || "").trim();
|
||||||
|
|
||||||
|
if (!source && !userId && !username && !role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTaskContext(task) {
|
||||||
|
const value = task?.context_json;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(value || "{}"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskClaimExpiresAt(task) {
|
||||||
|
return task?.claim_expires_at || task?.primary_claim_expires_at || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaimExpired(expiredAt) {
|
||||||
|
if (!expiredAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(expiredAt).getTime();
|
||||||
|
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { getOrderById } from "../../../repositories/order-repo.js";
|
||||||
|
import { createTaskEvent } from "../../../repositories/task-event-repo.js";
|
||||||
|
import { updateTask } from "../../../repositories/task-repo.js";
|
||||||
|
import { createHttpError } from "../../../utils/http.js";
|
||||||
|
import { nowIso } from "../../../utils/time.js";
|
||||||
|
import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-notifications.js";
|
||||||
|
import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js";
|
||||||
|
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||||
|
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||||
|
import {
|
||||||
|
getKuaishouEticketSourceConfig,
|
||||||
|
resolveKuaishouEticketShopConfig,
|
||||||
|
} from "../../platforms/kuaishou-eticket/source-config-service.js";
|
||||||
|
import {
|
||||||
|
isKuaishouCloudTask,
|
||||||
|
maskCode,
|
||||||
|
maskPhone,
|
||||||
|
normalizeKuaishouCloudFlow,
|
||||||
|
type JsonObject,
|
||||||
|
} from "./domain.js";
|
||||||
|
import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js";
|
||||||
|
import { normalizeActor, parseTaskContext } from "./task-context.js";
|
||||||
|
|
||||||
|
export async function dispatchKuaishouCloudFulfillmentTask(
|
||||||
|
task,
|
||||||
|
options: JsonObject = {}
|
||||||
|
) {
|
||||||
|
if (!isKuaishouCloudTask(task)) {
|
||||||
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_task_invalid",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = normalizeActor(options.actor);
|
||||||
|
const now = nowIso();
|
||||||
|
const taskContext = parseTaskContext(task);
|
||||||
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
||||||
|
const effectiveSourceKey =
|
||||||
|
flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
|
||||||
|
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
|
||||||
|
effectiveSourceKey,
|
||||||
|
flow.binding.cloudSourceKeyFallbacks || []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) {
|
||||||
|
throw createHttpError(
|
||||||
|
"当前任务还没有准备好绑定资源,请先完成绑定资源准备",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_not_prepared",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketCode = String(options.ticketCode || "").trim();
|
||||||
|
const persistedTicketCode = String(flow.ticket.code || "").trim();
|
||||||
|
if (!persistedTicketCode && !ticketCode) {
|
||||||
|
throw createHttpError("客户还没有提交有效核销码,暂时不能继续兑换", {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_missing_ticket_code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
flow.dispatch.status === "success" &&
|
||||||
|
String(task.task_status || "").trim() === "dispatched_pending_return"
|
||||||
|
) {
|
||||||
|
return { task, flow };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchResult = await useCloudtentaclesSku({
|
||||||
|
...cloudContext,
|
||||||
|
id: flow.binding.skuId,
|
||||||
|
virtualNumberId: flow.binding.vnId,
|
||||||
|
phone: flow.binding.vnPhone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextContext = {
|
||||||
|
...taskContext,
|
||||||
|
kuaishouCloudFulfillment: {
|
||||||
|
...flow,
|
||||||
|
ticket: {
|
||||||
|
...flow.ticket,
|
||||||
|
code: ticketCode || persistedTicketCode,
|
||||||
|
capturedAt: ticketCode ? now : flow.ticket.capturedAt,
|
||||||
|
capturedBy: ticketCode && actor ? actor : flow.ticket.capturedBy,
|
||||||
|
},
|
||||||
|
dispatch: {
|
||||||
|
...flow.dispatch,
|
||||||
|
status: "success",
|
||||||
|
dispatchAt: now,
|
||||||
|
dispatchBy: actor,
|
||||||
|
sendType: Number(dispatchResult.sendType || 0) || 0,
|
||||||
|
note: String(
|
||||||
|
dispatchResult.note ||
|
||||||
|
dispatchResult.responseMessage ||
|
||||||
|
"cloudtentacles 发货成功"
|
||||||
|
).trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let updatedTask = await updateTask(task.id, {
|
||||||
|
task_status: "dispatched_pending_return",
|
||||||
|
delivery_status: "delivered",
|
||||||
|
result_code: "kuaishou_cloud_dispatched",
|
||||||
|
result_message: String(
|
||||||
|
dispatchResult.responseMessage ||
|
||||||
|
dispatchResult.note ||
|
||||||
|
"cloudtentacles 发货成功"
|
||||||
|
).trim(),
|
||||||
|
user_action_status: "not_required",
|
||||||
|
last_error: "",
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createTaskEvent(
|
||||||
|
task.id,
|
||||||
|
"kuaishou_cloud_dispatched",
|
||||||
|
{
|
||||||
|
source: String(options.source || "system").trim() || "system",
|
||||||
|
ticketCodeMasked: maskCode(ticketCode || persistedTicketCode),
|
||||||
|
skuId: flow.binding.skuId,
|
||||||
|
vnId: flow.binding.vnId,
|
||||||
|
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||||
|
sendType: dispatchResult.sendType,
|
||||||
|
note: dispatchResult.note,
|
||||||
|
actor,
|
||||||
|
},
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldAutoFinalize =
|
||||||
|
options.autoFinalize === true &&
|
||||||
|
normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
|
||||||
|
.returnNumber.autoReturnEnabled === true;
|
||||||
|
|
||||||
|
if (shouldAutoFinalize) {
|
||||||
|
const finalizeResult = await returnKuaishouCloudFulfillmentTask(
|
||||||
|
updatedTask,
|
||||||
|
{
|
||||||
|
actor,
|
||||||
|
source: options.source || "system_auto_finalize",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
updatedTask = finalizeResult.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: updatedTask,
|
||||||
|
flow: normalizeKuaishouCloudFlow(
|
||||||
|
parseTaskContext(updatedTask).kuaishouCloudFulfillment
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnKuaishouCloudFulfillmentTask(
|
||||||
|
task,
|
||||||
|
options: JsonObject = {}
|
||||||
|
) {
|
||||||
|
if (!isKuaishouCloudTask(task)) {
|
||||||
|
throw createHttpError("当前任务不是快手 Cloud 履约任务", {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_task_invalid",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = normalizeActor(options.actor);
|
||||||
|
const now = nowIso();
|
||||||
|
const taskContext = parseTaskContext(task);
|
||||||
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment);
|
||||||
|
const effectiveSourceKey =
|
||||||
|
flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default";
|
||||||
|
const cloudContext = resolvePersistedCloudtentaclesContextWithFallback(
|
||||||
|
effectiveSourceKey,
|
||||||
|
flow.binding.cloudSourceKeyFallbacks || []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow.binding.vnId || !flow.binding.vnKey) {
|
||||||
|
throw createHttpError("当前任务缺少可退还的虚拟号信息", {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: "kuaishou_cloud_missing_return_context",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
flow.returnNumber.status === "success" &&
|
||||||
|
["completed", "manual_review"].includes(
|
||||||
|
String(task.task_status || "").trim()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { task, flow };
|
||||||
|
}
|
||||||
|
|
||||||
|
await backCloudtentaclesVirtualNumber({
|
||||||
|
...cloudContext,
|
||||||
|
key: flow.binding.vnKey,
|
||||||
|
id: flow.binding.vnId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = await getOrderById(task.order_id);
|
||||||
|
const ticketCode = String(flow.ticket.code || "").trim();
|
||||||
|
const shopId = String(flow.consume.shopId || order?.shop_id || "").trim();
|
||||||
|
const shopName = String(
|
||||||
|
flow.consume.shopName || order?.shop_name || ""
|
||||||
|
).trim();
|
||||||
|
const eticketSource = getKuaishouEticketSourceConfig();
|
||||||
|
const shopConfig = resolveKuaishouEticketShopConfig({
|
||||||
|
shopId,
|
||||||
|
shopName,
|
||||||
|
});
|
||||||
|
|
||||||
|
let consumeStatus = "pending";
|
||||||
|
let consumeErrorMessage = "";
|
||||||
|
let consumedAt = null;
|
||||||
|
let nextTaskStatus = "completed";
|
||||||
|
let nextResultCode = "kuaishou_cloud_completed";
|
||||||
|
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
|
||||||
|
} else if (!ticketCode) {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销";
|
||||||
|
} else if (
|
||||||
|
!shopConfig ||
|
||||||
|
shopConfig.enabled === false ||
|
||||||
|
!String(shopConfig.cookie || "").trim()
|
||||||
|
) {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage = "订单对应快手小店缺少可用 Cookie,无法执行快手核销";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const consumeResult = await consumeKuaishouEticket({
|
||||||
|
baseUrl: eticketSource.baseUrl,
|
||||||
|
cookie: shopConfig.cookie,
|
||||||
|
eTicketId: ticketCode,
|
||||||
|
oid: String(flow.ticket.oid || "").trim(),
|
||||||
|
formToken: String(flow.ticket.formToken || "").trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (consumeResult.consumed) {
|
||||||
|
consumeStatus = "success";
|
||||||
|
consumedAt = now;
|
||||||
|
} else {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage = String(
|
||||||
|
consumeResult.errorMessage || "快手核销失败"
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage =
|
||||||
|
error instanceof Error ? error.message : "快手核销失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consumeStatus !== "success") {
|
||||||
|
nextTaskStatus = "manual_review";
|
||||||
|
nextResultCode = "kuaishou_cloud_consume_failed";
|
||||||
|
nextResultMessage =
|
||||||
|
consumeErrorMessage || "号码已退还,但快手核销未完成,请人工处理";
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextContext = {
|
||||||
|
...taskContext,
|
||||||
|
kuaishouCloudFulfillment: {
|
||||||
|
...flow,
|
||||||
|
returnNumber: {
|
||||||
|
...flow.returnNumber,
|
||||||
|
status: "success",
|
||||||
|
returnedAt: now,
|
||||||
|
returnedBy: actor,
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
...flow.consume,
|
||||||
|
status: consumeStatus,
|
||||||
|
shopId: shopId || flow.consume.shopId,
|
||||||
|
shopName,
|
||||||
|
autoConsumeEnabled: flow.consume.autoConsumeEnabled === true,
|
||||||
|
consumedAt,
|
||||||
|
errorMessage: consumeErrorMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
|
task_status: nextTaskStatus,
|
||||||
|
delivery_status: "delivered",
|
||||||
|
result_code: nextResultCode,
|
||||||
|
result_message: nextResultMessage,
|
||||||
|
redeemed_at: consumeStatus === "success" ? now : task.redeemed_at,
|
||||||
|
last_error: consumeErrorMessage,
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createTaskEvent(
|
||||||
|
task.id,
|
||||||
|
"kuaishou_cloud_number_returned",
|
||||||
|
{
|
||||||
|
source: String(options.source || "system").trim() || "system",
|
||||||
|
vnId: flow.binding.vnId,
|
||||||
|
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
|
||||||
|
actor,
|
||||||
|
},
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
await createTaskEvent(
|
||||||
|
task.id,
|
||||||
|
consumeStatus === "success"
|
||||||
|
? "kuaishou_cloud_consumed"
|
||||||
|
: "kuaishou_cloud_consume_failed",
|
||||||
|
{
|
||||||
|
source: String(options.source || "system").trim() || "system",
|
||||||
|
ticketCodeMasked: maskCode(ticketCode),
|
||||||
|
shopId,
|
||||||
|
shopName,
|
||||||
|
consumeStatus,
|
||||||
|
errorMessage: consumeErrorMessage,
|
||||||
|
actor,
|
||||||
|
},
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
if (consumeStatus !== "success") {
|
||||||
|
await notifyKuaishouCloudConsumeFailed({
|
||||||
|
task: updatedTask,
|
||||||
|
order,
|
||||||
|
ticketCodeMasked: maskCode(ticketCode),
|
||||||
|
shopId,
|
||||||
|
shopName,
|
||||||
|
errorMessage: consumeErrorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: updatedTask,
|
||||||
|
flow: normalizeKuaishouCloudFlow(
|
||||||
|
parseTaskContext(updatedTask).kuaishouCloudFulfillment
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
import { runtimeConfig } from "../config/runtime.js";
|
||||||
|
import { runDatabaseMigrations } from "../db/migrate.js";
|
||||||
|
import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js";
|
||||||
|
import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js";
|
||||||
|
import { startScheduledJobs } from "../services/scheduler/scheduler-service.js";
|
||||||
|
import { warmupOcrService } from "../services/session/ocr.js";
|
||||||
|
import { warmupTencentBrowser } from "../services/session/session.js";
|
||||||
|
import { logError, logInfo, logWarn } from "../utils/logger.js";
|
||||||
|
import { formatStartupError, type StartupState } from "./state.js";
|
||||||
|
|
||||||
|
const CORE_BOOT_RETRY_DELAY_MS = 5_000;
|
||||||
|
|
||||||
|
export async function bootstrapCoreServices(
|
||||||
|
startupState: StartupState,
|
||||||
|
isShutdownStarted: () => boolean
|
||||||
|
) {
|
||||||
|
if (startupState.core.running || isShutdownStarted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupState.core.running = true;
|
||||||
|
|
||||||
|
while (!isShutdownStarted() && !startupState.core.ready) {
|
||||||
|
startupState.phase = startupState.core.attemptCount === 0 ? "starting" : "retrying";
|
||||||
|
startupState.core.attemptCount += 1;
|
||||||
|
startupState.core.lastAttemptAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logInfo("[startup]", "开始执行核心启动步骤", {
|
||||||
|
attempt: startupState.core.attemptCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runDatabaseMigrations();
|
||||||
|
await ensureFulfillmentCatalogBootstrapped();
|
||||||
|
await ensureAdminUsersBootstrapped();
|
||||||
|
|
||||||
|
startupState.core.ready = true;
|
||||||
|
startupState.core.lastError = "";
|
||||||
|
startupState.phase = "ready";
|
||||||
|
startupState.core.readyAt = new Date().toISOString();
|
||||||
|
|
||||||
|
logInfo("[startup]", "核心启动步骤完成,服务已就绪", {
|
||||||
|
attempt: startupState.core.attemptCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
startScheduledJobs();
|
||||||
|
void bootstrapBrowser(startupState);
|
||||||
|
void bootstrapOcr(startupState);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const message = formatStartupError(error);
|
||||||
|
startupState.phase = "retrying";
|
||||||
|
startupState.core.lastError = message;
|
||||||
|
logError("[startup]", "核心启动步骤失败,将自动重试", {
|
||||||
|
attempt: startupState.core.attemptCount,
|
||||||
|
retryDelayMs: CORE_BOOT_RETRY_DELAY_MS,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(CORE_BOOT_RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startupState.core.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapBrowser(startupState: StartupState) {
|
||||||
|
startupState.browser.status = "starting";
|
||||||
|
startupState.browser.lastAttemptAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await warmupTencentBrowser();
|
||||||
|
|
||||||
|
if (!result.warmed) {
|
||||||
|
startupState.browser.status = "skipped";
|
||||||
|
startupState.browser.message = "browser prewarm skipped";
|
||||||
|
logInfo("[startup]", "browser prewarm skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupState.browser.status = "ready";
|
||||||
|
startupState.browser.message = "browser prewarm ready";
|
||||||
|
logInfo("[startup]", "browser prewarm ready");
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingPlaywrightBrowserError(error)) {
|
||||||
|
const installCommand = resolveBrowserInstallCommand();
|
||||||
|
startupState.browser.status = "degraded";
|
||||||
|
startupState.browser.message = formatErrorMessage(error);
|
||||||
|
logWarn("[startup]", `browser prewarm skipped: ${formatErrorMessage(error)}`);
|
||||||
|
logWarn("[startup]", `请先安装浏览器依赖: ${installCommand}`);
|
||||||
|
|
||||||
|
if (String(runtimeConfig.browser.chromePath || "").trim()) {
|
||||||
|
logWarn("[startup]", "当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startupState.browser.status = "degraded";
|
||||||
|
startupState.browser.message = formatStartupError(error);
|
||||||
|
logError("[startup]", "browser prewarm failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapOcr(startupState: StartupState) {
|
||||||
|
startupState.ocr.status = "starting";
|
||||||
|
startupState.ocr.lastAttemptAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await warmupOcrService();
|
||||||
|
startupState.ocr.status = "ready";
|
||||||
|
startupState.ocr.message = "OCR service ready";
|
||||||
|
logInfo("[startup]", "OCR service ready");
|
||||||
|
} catch (error) {
|
||||||
|
startupState.ocr.status = "degraded";
|
||||||
|
startupState.ocr.message = formatStartupError(error);
|
||||||
|
logWarn("[startup]", `OCR service skipped: ${formatStartupError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingPlaywrightBrowserError(error: unknown) {
|
||||||
|
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: unknown) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js";
|
||||||
|
import { closeOcrService } from "../services/session/ocr.js";
|
||||||
|
import { closeAllTencentBrowserSessions } from "../services/session/session.js";
|
||||||
|
import { logError, logInfo } from "../utils/logger.js";
|
||||||
|
import type { StartupState } from "./state.js";
|
||||||
|
|
||||||
|
export function createShutdownController(server: Server, startupState: StartupState) {
|
||||||
|
let shutdownStarted = false;
|
||||||
|
|
||||||
|
async function shutdown(signal: string) {
|
||||||
|
if (shutdownStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownStarted = true;
|
||||||
|
startupState.phase = "shutting_down";
|
||||||
|
logInfo("[shutdown]", `received ${signal}, closing browser sessions and HTTP server`);
|
||||||
|
|
||||||
|
stopScheduledJobs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeAllTencentBrowserSessions({ markClosed: true });
|
||||||
|
} catch (error) {
|
||||||
|
logError("[shutdown]", "failed to close browser sessions", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await closeOcrService();
|
||||||
|
} catch (error) {
|
||||||
|
logError("[shutdown]", "failed to close OCR service", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.listening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
logError("[shutdown]", "failed to close HTTP server", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isShutdownStarted: () => shutdownStarted,
|
||||||
|
shutdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type StartupState = ReturnType<typeof createStartupState>;
|
||||||
|
|
||||||
|
export function createStartupState() {
|
||||||
|
return {
|
||||||
|
phase: "starting",
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
core: {
|
||||||
|
ready: false,
|
||||||
|
running: false,
|
||||||
|
attemptCount: 0,
|
||||||
|
readyAt: "",
|
||||||
|
lastAttemptAt: "",
|
||||||
|
lastError: "",
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
status: "pending",
|
||||||
|
message: "",
|
||||||
|
lastAttemptAt: "",
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
status: "pending",
|
||||||
|
message: "",
|
||||||
|
lastAttemptAt: "",
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
lastUnhandledRejection: null,
|
||||||
|
lastUncaughtException: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHealthPayload(startupState: StartupState, shutdownStarted: boolean) {
|
||||||
|
return {
|
||||||
|
phase: startupState.phase,
|
||||||
|
startedAt: startupState.startedAt,
|
||||||
|
core: {
|
||||||
|
ready: startupState.core.ready,
|
||||||
|
running: startupState.core.running,
|
||||||
|
attemptCount: startupState.core.attemptCount,
|
||||||
|
readyAt: startupState.core.readyAt,
|
||||||
|
lastAttemptAt: startupState.core.lastAttemptAt,
|
||||||
|
lastError: startupState.core.lastError,
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
status: startupState.browser.status,
|
||||||
|
message: startupState.browser.message,
|
||||||
|
lastAttemptAt: startupState.browser.lastAttemptAt,
|
||||||
|
},
|
||||||
|
ocr: {
|
||||||
|
status: startupState.ocr.status,
|
||||||
|
message: startupState.ocr.message,
|
||||||
|
lastAttemptAt: startupState.ocr.lastAttemptAt,
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
pid: process.pid,
|
||||||
|
shutdownStarted,
|
||||||
|
lastUnhandledRejection: startupState.process.lastUnhandledRejection,
|
||||||
|
lastUncaughtException: startupState.process.lastUncaughtException,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStartupError(error: unknown) {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message.split("\n")[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error || "未知错误").trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user