diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 00000000..13ed43eb --- /dev/null +++ b/apps/backend/src/app.ts @@ -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; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f73459b2..997d7d33 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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 | 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((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); +}); diff --git a/apps/backend/src/middleware/access-log.ts b/apps/backend/src/middleware/access-log.ts new file mode 100644 index 00000000..bfa02d2d --- /dev/null +++ b/apps/backend/src/middleware/access-log.ts @@ -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; +} diff --git a/apps/backend/src/middleware/cors.ts b/apps/backend/src/middleware/cors.ts new file mode 100644 index 00000000..570508ae --- /dev/null +++ b/apps/backend/src/middleware/cors.ts @@ -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(); +} diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index dcf7492d..3be79e58 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -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; - 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; diff --git a/apps/backend/src/routes/admin/platform-config/agiso.ts b/apps/backend/src/routes/admin/platform-config/agiso.ts new file mode 100644 index 00000000..d92a7738 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/agiso.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts new file mode 100644 index 00000000..26852a12 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts new file mode 100644 index 00000000..054b92dc --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts new file mode 100644 index 00000000..c76b106f --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts new file mode 100644 index 00000000..0979b248 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts new file mode 100644 index 00000000..51ffb0bb --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/notifications.ts b/apps/backend/src/routes/admin/platform-config/notifications.ts new file mode 100644 index 00000000..c44dc157 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/notifications.ts @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/shared.ts b/apps/backend/src/routes/admin/platform-config/shared.ts new file mode 100644 index 00000000..f3e9a9e6 --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/shared.ts @@ -0,0 +1 @@ +export type JsonRecord = Record diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts index d0dd4c31..702f6d75 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts @@ -1,287 +1,62 @@ import { createTaskEvent } from "../../repositories/task-event-repo.js"; -import { getOrderById } from "../../repositories/order-repo.js"; import { updateTask } from "../../repositories/task-repo.js"; import { buildClaimUrl, createTaskClaimToken } from "../claim/claim-service.js"; -import { normalizeProductName } from "../order/product-match-service.js"; -import { - getCloudtentaclesSourceConfig, - getCloudtentaclesSourceByKey, -} from "../platforms/cloudtentacles/source-config-service.js"; -import { - getCloudtentaclesSessionState, - getCloudtentaclesSessionStateByKey, -} from "../platforms/cloudtentacles/session-state-service.js"; import { buyCloudtentaclesSku, getCloudtentaclesAsset, listCloudtentaclesSku, - useCloudtentaclesSku, } from "../platforms/cloudtentacles/catalog-service.js"; import { getCloudtentaclesKnapsack } from "../platforms/cloudtentacles/knapsack-service.js"; import { - appointCloudtentaclesVirtualNumber, backCloudtentaclesVirtualNumber, - fetchCloudtentaclesVirtualNumberCode, - generateCloudtentaclesLoginCode, getCloudtentaclesBindInfo, - getCloudtentaclesBindUrl, probeCloudtentaclesBindUrl, - verifyCloudtentaclesLoginCode, } from "../platforms/cloudtentacles/virtual-number-service.js"; import { resolveCloudtentaclesConfig } from "../platforms/cloudtentacles/shared.js"; -import { consumeKuaishouEticket } from "../platforms/kuaishou-eticket/consume-service.js"; -import { - getKuaishouEticketSourceConfig, - resolveKuaishouEticketShopConfig, -} from "../platforms/kuaishou-eticket/source-config-service.js"; import { notifyKuaishouCloudAssetNotEnough, notifyKuaishouCloudBindUrlRefreshFailed, - notifyKuaishouCloudConsumeFailed, } from "../notification/domain-notifications.js"; import { createHttpError } from "../../utils/http.js"; import { nowIso } from "../../utils/time.js"; +import { + KUAISHOU_CLOUD_FIXED_VN_KEY, + isKuaishouCloudBindUrlFresh, + isKuaishouCloudTask, + maskPhone, + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + resolveKuaishouCloudBindUrlExpiresAt, + type JsonObject, +} from "./kuaishou-cloud/domain.js"; +import { + prepareKuaishouCloudBindResourceWithFallback, + resolveKuaishouCloudBindingResources, + resolveKuaishouCloudVnKeyCandidates, +} from "./kuaishou-cloud/binding-resources.js"; +import { + resolvePersistedCloudtentaclesContextWithFallback, +} from "./kuaishou-cloud/cloudtentacles-context.js"; +import { + getTaskClaimExpiresAt, + isClaimExpired, + normalizeActor, + parseTaskContext, +} from "./kuaishou-cloud/task-context.js"; -const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; - -type JsonObject = Record; - -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 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; - - // 该账号没有 token → 标记跳过但不报错 - 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" } - ) - ); -} +export { + isKuaishouCloudBindUrlFresh, + isKuaishouCloudTask, + maskCode, + maskPhone, + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + resolveKuaishouCloudBindUrlExpiresAt, +} from "./kuaishou-cloud/domain.js"; +export { + resolvePersistedCloudtentaclesContext, + resolvePersistedCloudtentaclesContextWithFallback, +} from "./kuaishou-cloud/cloudtentacles-context.js"; export async function ensureTaskClaimLink(task) { const tokenStatus = String(task?.primary_claim_token_status || "").trim(); @@ -997,676 +772,7 @@ export async function refreshKuaishouCloudTaskRoleInfo( }; } -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 - ), - }; -} - -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(); -} - -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), - }; -} - -function resolveKuaishouCloudVnKeyCandidates(_input = {}) { - return [KUAISHOU_CLOUD_FIXED_VN_KEY]; -} - -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("不支持的游戏类型") - ); -} - -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, - }; -} - -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 {}; - } -} - -function getTaskClaimExpiresAt(task) { - return task?.claim_expires_at || task?.primary_claim_expires_at || null; -} - -function isClaimExpired(expiredAt) { - if (!expiredAt) { - return false; - } - - const timestamp = new Date(expiredAt).getTime(); - return Number.isFinite(timestamp) && timestamp <= Date.now(); -} - -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 []; -} +export { + dispatchKuaishouCloudFulfillmentTask, + returnKuaishouCloudFulfillmentTask, +} from "./kuaishou-cloud/task-finalization.js"; diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts new file mode 100644 index 00000000..69b22b04 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -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("不支持的游戏类型") + ); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts new file mode 100644 index 00000000..b8b0a81e --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts @@ -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" } + ) + ); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts new file mode 100644 index 00000000..5c16e519 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -0,0 +1,223 @@ +import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js"; + +export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; + +export type JsonObject = Record; + +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 []; +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts new file mode 100644 index 00000000..af5f097b --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts @@ -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(); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts new file mode 100644 index 00000000..90414207 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -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 + ), + }; +} diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts new file mode 100644 index 00000000..4d831656 --- /dev/null +++ b/apps/backend/src/startup/bootstrap.ts @@ -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); + }); +} diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts new file mode 100644 index 00000000..e87c36c4 --- /dev/null +++ b/apps/backend/src/startup/shutdown.ts @@ -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((resolve) => { + server.close((error) => { + if (error) { + logError("[shutdown]", "failed to close HTTP server", error); + process.exitCode = 1; + } + + resolve(); + }); + }); + } + + return { + isShutdownStarted: () => shutdownStarted, + shutdown, + }; +} diff --git a/apps/backend/src/startup/state.ts b/apps/backend/src/startup/state.ts new file mode 100644 index 00000000..0f54f9c3 --- /dev/null +++ b/apps/backend/src/startup/state.ts @@ -0,0 +1,69 @@ +export type StartupState = ReturnType; + +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(); +}