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 { runDatabaseMigrations } from './db/migrate.js'
|
||||
import adminRouter from './routes/admin.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
import open91Router from './routes/open-91.js'
|
||||
import webhooksRouter from './routes/webhooks.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'
|
||||
import { createApp } from "./app.js";
|
||||
import { runtimeConfig } from "./config/runtime.js";
|
||||
import { bootstrapCoreServices } from "./startup/bootstrap.js";
|
||||
import { createShutdownController } from "./startup/shutdown.js";
|
||||
import { createStartupState, formatStartupError } from "./startup/state.js";
|
||||
import { logError, logInfo } from "./utils/logger.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()
|
||||
const port = Number(runtimeConfig.server.port || 3000)
|
||||
const host = '0.0.0.0'
|
||||
const startupState = createStartupState()
|
||||
let shutdownController: ReturnType<typeof createShutdownController> | null = null;
|
||||
|
||||
app.use((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()
|
||||
})
|
||||
|
||||
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 app = createApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||
});
|
||||
|
||||
const server = app.listen(port, host, () => {
|
||||
logInfo('[startup]', `order-site-backend listening on http://${host}:${port}`)
|
||||
void bootstrapCoreServices()
|
||||
})
|
||||
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
||||
void bootstrapCoreServices(
|
||||
startupState,
|
||||
() => shutdownController?.isShutdownStarted() || false
|
||||
);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
logError('[startup]', 'HTTP server failed', error)
|
||||
})
|
||||
shutdownController = createShutdownController(server, startupState);
|
||||
|
||||
async function bootstrapCoreServices() {
|
||||
if (startupState.core.running || shutdownStarted) {
|
||||
return
|
||||
}
|
||||
server.on("error", (error) => {
|
||||
logError("[startup]", "HTTP server failed", error);
|
||||
});
|
||||
|
||||
startupState.core.running = true
|
||||
process.on("SIGINT", () => {
|
||||
void shutdownController?.shutdown("SIGINT");
|
||||
});
|
||||
|
||||
while (!shutdownStarted && !startupState.core.ready) {
|
||||
startupState.phase = startupState.core.attemptCount === 0 ? 'starting' : 'retrying'
|
||||
startupState.core.attemptCount += 1
|
||||
startupState.core.lastAttemptAt = new Date().toISOString()
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdownController?.shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
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()
|
||||
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) => {
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
startupState.process.lastUnhandledRejection = {
|
||||
time: new Date().toISOString(),
|
||||
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 = {
|
||||
time: new Date().toISOString(),
|
||||
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 {
|
||||
appointAdminCloudtentaclesVirtualNumber,
|
||||
backAdminCloudtentaclesVirtualNumber,
|
||||
buyAdminCloudtentaclesSku,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
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";
|
||||
import { requireAdminRoles } from "./shared.js";
|
||||
import agisoRouter from "./platform-config/agiso.js";
|
||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
||||
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
|
||||
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||
import notificationsRouter from "./platform-config/notifications.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
type JsonRecord = Record<string, any>;
|
||||
|
||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||
|
||||
router.get(
|
||||
"/platform-config/agiso-shops",
|
||||
createJsonHandler(() => getAdminAgisoShopConfigs(), {
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 Agiso 店铺配置失败",
|
||||
scope: "[admin/platform-config/agiso-shops]",
|
||||
})
|
||||
);
|
||||
|
||||
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(),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
router.use("/platform-config", agisoRouter);
|
||||
router.use("/platform-config", notificationsRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", ninetyoneRouter);
|
||||
router.use("/platform-config", cloudtentaclesRouter);
|
||||
router.use("/platform-config", fulfillmentBindingsRouter);
|
||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||
|
||||
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