删除咸鱼旧链路
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import express from "express";
|
||||
import process from "node:process";
|
||||
|
||||
import adminRouter from "./routes/admin-kuaishou.js";
|
||||
import claimsRouter from "./routes/claims-kuaishou.js";
|
||||
import open91Router from "./routes/open-91.js";
|
||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||
|
||||
type CreateKuaishouAppOptions = {
|
||||
startupState: StartupState;
|
||||
isShutdownStarted: () => boolean;
|
||||
config: RuntimeConfig;
|
||||
};
|
||||
|
||||
export function createKuaishouApp({
|
||||
startupState,
|
||||
isShutdownStarted,
|
||||
config,
|
||||
}: CreateKuaishouAppOptions) {
|
||||
const app = express();
|
||||
|
||||
app.use(accessLogMiddleware);
|
||||
app.use(createCorsMiddleware(config));
|
||||
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,
|
||||
mode: "kuaishou-lite",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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/open/91", open91Router);
|
||||
app.use("/api/v1/claim", claimsRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
sendRouteError(res, err, "服务内部错误", "[global:kuaishou]");
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
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, sendRouteError } from "./utils/http.js";
|
||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||
|
||||
type CreateAppOptions = {
|
||||
startupState: StartupState;
|
||||
isShutdownStarted: () => boolean;
|
||||
@@ -44,6 +43,7 @@ export function createApp({
|
||||
buildSuccessPayload({
|
||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||
pid: process.pid,
|
||||
mode: "kuaishou-lite",
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -89,9 +89,7 @@ export function createApp({
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
browser: {
|
||||
chromePath: "",
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: "1600x900x24",
|
||||
},
|
||||
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
|
||||
ocr: {
|
||||
baseUrl: "",
|
||||
},
|
||||
|
||||
data: {
|
||||
root: path.resolve(projectRoot, "data"),
|
||||
},
|
||||
@@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
},
|
||||
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: "",
|
||||
tradeDetail: {
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail",
|
||||
apiVersion: "1",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend",
|
||||
apiVersion: "1",
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint:
|
||||
"https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg",
|
||||
apiVersion: "1",
|
||||
messageTemplate:
|
||||
"您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}",
|
||||
autoDeliveryMessageTemplate:
|
||||
"您的订单 {platformOrderId} 已完成自动发货,请注意查收。",
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: "",
|
||||
secret: "",
|
||||
@@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cors: {
|
||||
allowedOrigins: ["*"],
|
||||
},
|
||||
|
||||
redeem: {
|
||||
proofMode: "full",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'OCR_IMAGE',
|
||||
'OCR_PORT',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_PASSWORD',
|
||||
'POSTGRES_PORT',
|
||||
'POSTGRES_USER',
|
||||
'TENCENT_BROWSER_NOVNC_ENABLED',
|
||||
'TENCENT_BROWSER_NOVNC_PORT',
|
||||
'TENCENT_BROWSER_VNC_PORT',
|
||||
'TZ',
|
||||
'VITE_API_TARGET',
|
||||
'VITE_ALLOWED_HOSTS',
|
||||
]
|
||||
|
||||
const KNOWN_ENV_NAMES = new Set([
|
||||
|
||||
@@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
||||
|
||||
const config = applyEnvOverrides(baseConfig, {
|
||||
PORT: '4000',
|
||||
TENCENT_BROWSER_HEADLESS: 'true',
|
||||
TENCENT_BROWSER_SLOW_MO: '250',
|
||||
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
|
||||
DATA_ROOT: './tmp/backend-data',
|
||||
DATABASE_SSL: 'yes',
|
||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
|
||||
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
||||
TENCENT_REDEEM_PROOF_MODE: 'basic',
|
||||
})
|
||||
|
||||
assert.equal(config.server.port, 4000)
|
||||
assert.equal(config.browser.headless, true)
|
||||
assert.equal(config.browser.slowMoMs, 250)
|
||||
assert.equal(config.browser.vncResolution, '1920x1080x24')
|
||||
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
||||
assert.equal(config.database.ssl, true)
|
||||
assert.deepEqual(config.admin.defaultUsers, [
|
||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||
])
|
||||
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
|
||||
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
||||
assert.equal(config.redeem.proofMode, 'basic')
|
||||
|
||||
assert.equal(baseConfig.server.port, 3000)
|
||||
assert.equal(baseConfig.browser.headless, null)
|
||||
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
||||
})
|
||||
|
||||
@@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
@@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
defaultUsers: [],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
deviceType: 0,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,6 @@ type EnvOverride = {
|
||||
|
||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv("PORT", ["server", "port"]),
|
||||
stringEnv("CHROME_PATH", ["browser", "chromePath"]),
|
||||
booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]),
|
||||
booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]),
|
||||
booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]),
|
||||
booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]),
|
||||
integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]),
|
||||
stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]),
|
||||
booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]),
|
||||
stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]),
|
||||
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||
@@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
||||
stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"apiVersion",
|
||||
]),
|
||||
integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"timeoutMs",
|
||||
]),
|
||||
booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_MESSAGE_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"sendMessageEndpoint",
|
||||
]),
|
||||
booleanEnv("AGISO_MESSAGING_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
|
||||
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
"cloudtentacles",
|
||||
"deviceType",
|
||||
]),
|
||||
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
|
||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||
];
|
||||
|
||||
|
||||
@@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
deviceType: 0,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}, overrides) as RuntimeConfig
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
|
||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||
requireInteger(
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import process from "node:process";
|
||||
|
||||
import { createKuaishouApp } from "./app-kuaishou.js";
|
||||
import { runtimeConfig } from "./config/runtime.js";
|
||||
import { assertRuntimeConfigValid } from "./config/runtime-validation.js";
|
||||
import { bootstrapKuaishouCoreServices } from "./startup/bootstrap-kuaishou.js";
|
||||
import { createKuaishouShutdownController } from "./startup/shutdown-kuaishou.js";
|
||||
import { createStartupState, formatStartupError } from "./startup/state.js";
|
||||
import { logError, logInfo } from "./utils/logger.js";
|
||||
|
||||
const port = Number(runtimeConfig.server.port || 3000);
|
||||
const host = "0.0.0.0";
|
||||
const startupState = createStartupState();
|
||||
|
||||
let shutdownController: ReturnType<
|
||||
typeof createKuaishouShutdownController
|
||||
> | null = null;
|
||||
|
||||
try {
|
||||
assertRuntimeConfigValid(runtimeConfig);
|
||||
} catch (error) {
|
||||
logError("[startup:kuaishou]", "运行时配置校验失败,服务停止启动", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = createKuaishouApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||
config: runtimeConfig,
|
||||
});
|
||||
|
||||
const server = app.listen(port, host, () => {
|
||||
logInfo(
|
||||
"[startup:kuaishou]",
|
||||
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
||||
);
|
||||
void bootstrapKuaishouCoreServices(
|
||||
startupState,
|
||||
() => shutdownController?.isShutdownStarted() || false
|
||||
);
|
||||
});
|
||||
|
||||
shutdownController = createKuaishouShutdownController(server, startupState);
|
||||
|
||||
server.on("error", (error) => {
|
||||
logError("[startup:kuaishou]", "HTTP server failed", error);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdownController?.shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdownController?.shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
startupState.process.lastUnhandledRejection = {
|
||||
time: new Date().toISOString(),
|
||||
message: formatStartupError(reason),
|
||||
};
|
||||
logError("[process:kuaishou]", "unhandled promise rejection", reason);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
startupState.process.lastUncaughtException = {
|
||||
time: new Date().toISOString(),
|
||||
message: formatStartupError(error),
|
||||
};
|
||||
logError("[process:kuaishou]", "uncaught exception captured", error);
|
||||
});
|
||||
@@ -20,6 +20,7 @@ try {
|
||||
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||
@@ -27,7 +28,10 @@ const app = createApp({
|
||||
});
|
||||
|
||||
const server = app.listen(port, host, () => {
|
||||
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
||||
logInfo(
|
||||
"[startup]",
|
||||
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
||||
);
|
||||
void bootstrapCoreServices(
|
||||
startupState,
|
||||
() => shutdownController?.isShutdownStarted() || false
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import inventoryRouter from "./admin/inventory-kuaishou.js";
|
||||
import messageDeliveriesRouter from "./admin/message-deliveries.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config-kuaishou.js";
|
||||
import { requireAdminSession } from "./admin/shared.js";
|
||||
import tasksRouter from "./admin/tasks-kuaishou.js";
|
||||
import usersRouter from "./admin/users.js";
|
||||
import webhookEventsRouter from "./admin/webhook-events-kuaishou.js";
|
||||
import { buildNotFoundPayload } from "../utils/http.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authRouter);
|
||||
router.use(requireAdminSession);
|
||||
router.use(dashboardRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(auditLogsRouter);
|
||||
router.use(platformConfigRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(inventoryRouter);
|
||||
router.use(messageDeliveriesRouter);
|
||||
router.use(webhookEventsRouter);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,36 +1,34 @@
|
||||
import { Router } from 'express'
|
||||
import { Router } from "express";
|
||||
|
||||
import authRouter from './admin/auth.js'
|
||||
import auditLogsRouter from './admin/audit-logs.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import inventoryRouter from './admin/inventory.js'
|
||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
import { requireAdminSession } from './admin/shared.js'
|
||||
import tasksRouter from './admin/tasks.js'
|
||||
import usersRouter from './admin/users.js'
|
||||
import webhookEventsRouter from './admin/webhook-events.js'
|
||||
import { buildNotFoundPayload } from '../utils/http.js'
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import inventoryRouter from "./admin/inventory.js";
|
||||
import messageDeliveriesRouter from "./admin/message-deliveries.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
import { requireAdminSession } from "./admin/shared.js";
|
||||
import tasksRouter from "./admin/tasks.js";
|
||||
import usersRouter from "./admin/users.js";
|
||||
import webhookEventsRouter from "./admin/webhook-events.js";
|
||||
import { buildNotFoundPayload } from "../utils/http.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.use(authRouter)
|
||||
router.use(requireAdminSession)
|
||||
router.use(dashboardRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(ordersRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(manualRedeemRouter)
|
||||
router.use(inventoryRouter)
|
||||
router.use(messageDeliveriesRouter)
|
||||
router.use(webhookEventsRouter)
|
||||
router.use(authRouter);
|
||||
router.use(requireAdminSession);
|
||||
router.use(dashboardRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(auditLogsRouter);
|
||||
router.use(platformConfigRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(inventoryRouter);
|
||||
router.use(messageDeliveriesRouter);
|
||||
router.use(webhookEventsRouter);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from "../../services/admin/write/inventory.js";
|
||||
import {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||
import type {
|
||||
AdminInventoryCreateRouteBody,
|
||||
AdminInventoryImportRouteBody,
|
||||
AdminInventoryInvalidateRouteBody,
|
||||
AdminInventoryRouteParams,
|
||||
AdminInventoryRouteQuery,
|
||||
AdminInventorySkuSuggestionRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
|
||||
|
||||
router.get(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取库存列表失败",
|
||||
scope: "[admin-kuaishou/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/inventory/sku-suggestions",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取内部 SKU 建议失败",
|
||||
scope: "[admin-kuaishou/inventory/sku-suggestions]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: "库存项已新增",
|
||||
errorMessage: "新增库存项失败",
|
||||
scope: "[admin-kuaishou/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/inventory/import",
|
||||
createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: "导入完成",
|
||||
errorMessage: "导入库存凭据失败",
|
||||
scope: "[admin-kuaishou/inventory/import]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/inventory/:inventoryItemId/release",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
releaseAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
{
|
||||
successMessage: "库存项已释放",
|
||||
errorMessage: "释放库存项失败",
|
||||
scope: "[admin-kuaishou/inventory/:inventoryItemId/release]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse;
|
||||
|
||||
return {
|
||||
action: "inventory_item_released",
|
||||
targetType: "inventory_item",
|
||||
targetId: String(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
displayValue: result.inventoryItem?.displayValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/inventory/:inventoryItemId/invalidate",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
invalidateAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
||||
req.body as AdminInventoryInvalidateRouteBody
|
||||
),
|
||||
{
|
||||
successMessage: "库存项已作废",
|
||||
errorMessage: "作废库存项失败",
|
||||
scope: "[admin-kuaishou/inventory/:inventoryItemId/invalidate]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse;
|
||||
|
||||
return {
|
||||
action: "inventory_item_invalidated",
|
||||
targetType: "inventory_item",
|
||||
targetId: String(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
invalidReason: result.inventoryItem?.invalidReason,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Router } from 'express'
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
} from "../../services/admin/write/inventory.js";
|
||||
import {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||
import type {
|
||||
AdminInventoryCreateRouteBody,
|
||||
AdminInventoryImportRouteBody,
|
||||
@@ -18,100 +18,132 @@ import type {
|
||||
AdminInventoryRouteParams,
|
||||
AdminInventoryRouteQuery,
|
||||
AdminInventorySkuSuggestionRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js'
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.use('/inventory', requireAdminRoles(['admin', 'operator']))
|
||||
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
|
||||
|
||||
router.get('/inventory', createJsonHandler(
|
||||
(req) => getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取库存列表失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取库存列表失败",
|
||||
scope: "[admin/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
||||
(req) => getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取内部 SKU 建议失败',
|
||||
scope: '[admin/inventory/sku-suggestions]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/inventory/sku-suggestions",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取内部 SKU 建议失败",
|
||||
scope: "[admin/inventory/sku-suggestions]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory', createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: '库存项已新增',
|
||||
errorMessage: '新增库存项失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: "库存项已新增",
|
||||
errorMessage: "新增库存项失败",
|
||||
scope: "[admin/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory/import', createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: '导入完成',
|
||||
errorMessage: '导入库存凭据失败',
|
||||
scope: '[admin/inventory/import]',
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory/import",
|
||||
createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: "导入完成",
|
||||
errorMessage: "导入库存凭据失败",
|
||||
scope: "[admin/inventory/import]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
{
|
||||
successMessage: '库存项已释放',
|
||||
errorMessage: '释放库存项失败',
|
||||
scope: '[admin/inventory/:inventoryItemId/release]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse
|
||||
return {
|
||||
action: 'inventory_item_released',
|
||||
targetType: 'inventory_item',
|
||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
displayValue: result.inventoryItem?.displayValue,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory/:inventoryItemId/release",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
releaseAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
{
|
||||
successMessage: "库存项已释放",
|
||||
errorMessage: "释放库存项失败",
|
||||
scope: "[admin/inventory/:inventoryItemId/release]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse;
|
||||
|
||||
router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => invalidateAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
||||
req.body as AdminInventoryInvalidateRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: '库存项已作废',
|
||||
errorMessage: '作废库存项失败',
|
||||
scope: '[admin/inventory/:inventoryItemId/invalidate]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse
|
||||
return {
|
||||
action: 'inventory_item_invalidated',
|
||||
targetType: 'inventory_item',
|
||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
invalidReason: result.inventoryItem?.invalidReason,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
return {
|
||||
action: "inventory_item_released",
|
||||
targetType: "inventory_item",
|
||||
targetId: String(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
displayValue: result.inventoryItem?.displayValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router
|
||||
router.post(
|
||||
"/inventory/:inventoryItemId/invalidate",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
invalidateAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
||||
req.body as AdminInventoryInvalidateRouteBody
|
||||
),
|
||||
{
|
||||
successMessage: "库存项已作废",
|
||||
errorMessage: "作废库存项失败",
|
||||
scope: "[admin/inventory/:inventoryItemId/invalidate]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse;
|
||||
|
||||
return {
|
||||
action: "inventory_item_invalidated",
|
||||
targetType: "inventory_item",
|
||||
targetId: String(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
invalidReason: result.inventoryItem?.invalidReason,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeAdminManualRedeemSession,
|
||||
closeAdminManualRedeemTask,
|
||||
confirmAdminManualRedeemRole,
|
||||
createAdminManualRedeemSession,
|
||||
createAdminManualRedeemTask,
|
||||
getAdminManualRedeemDetail,
|
||||
getAdminManualRedeemSessionSummary,
|
||||
reloadAdminManualRedeemSession,
|
||||
redeemAdminManualRedeemTask,
|
||||
} from '../../services/admin/admin-manual-redeem-service.js'
|
||||
import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
import type {
|
||||
AdminInventorySkuSuggestionRouteQuery,
|
||||
AdminManualRedeemCreateRouteBody,
|
||||
AdminManualRedeemRouteParams,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
|
||||
type AdminManualRedeemSessionRouteBody = {
|
||||
loginType?: string
|
||||
forceRecreate?: boolean
|
||||
}
|
||||
|
||||
type AdminManualRedeemMutationResult = {
|
||||
task?: {
|
||||
taskId?: number
|
||||
taskNo?: string
|
||||
status?: string
|
||||
deliveryStatus?: string
|
||||
}
|
||||
manualRequest?: { proofValue?: string }
|
||||
order?: { platformOrderId?: string }
|
||||
orderItem?: { skuCode?: string }
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
|
||||
|
||||
router.get('/manual-redeem/sku-suggestions', createJsonHandler(
|
||||
(req) => getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工兑换 SKU 建议失败',
|
||||
scope: '[admin/manual-redeem/sku-suggestions]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem', createJsonHandler(
|
||||
(req) => createAdminManualRedeemTask(
|
||||
req.body as AdminManualRedeemCreateRouteBody,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换任务已创建',
|
||||
errorMessage: '创建人工兑换任务失败',
|
||||
scope: '[admin/manual-redeem]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminManualRedeemMutationResult
|
||||
return {
|
||||
action: 'manual_redeem_created',
|
||||
targetType: 'task',
|
||||
targetId: String(result.task?.taskId || ''),
|
||||
data: {
|
||||
taskId: result.task?.taskId,
|
||||
taskNo: result.task?.taskNo,
|
||||
proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '',
|
||||
skuCode: result.orderItem?.skuCode || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/manual-redeem/:taskId', createJsonHandler(
|
||||
(req) => getAdminManualRedeemDetail(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工兑换详情失败',
|
||||
scope: '[admin/manual-redeem/:taskId]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem/:taskId/session', createJsonHandler(
|
||||
(req) => createAdminManualRedeemSession(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.body as AdminManualRedeemSessionRouteBody,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换登录会话已创建',
|
||||
errorMessage: '创建人工兑换登录会话失败',
|
||||
scope: '[admin/manual-redeem/:taskId/session]',
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
|
||||
(req) => getAdminManualRedeemSessionSummary(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工兑换会话摘要失败',
|
||||
scope: '[admin/manual-redeem/:taskId/session/summary]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
|
||||
(req) => reloadAdminManualRedeemSession(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换会话已刷新',
|
||||
errorMessage: '刷新人工兑换会话失败',
|
||||
scope: '[admin/manual-redeem/:taskId/session/refresh]',
|
||||
},
|
||||
))
|
||||
|
||||
router.delete('/manual-redeem/:taskId/session', createJsonHandler(
|
||||
(req) => closeAdminManualRedeemSession(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换会话已关闭',
|
||||
errorMessage: '关闭人工兑换会话失败',
|
||||
scope: '[admin/manual-redeem/:taskId/session]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler(
|
||||
(req) => confirmAdminManualRedeemRole(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '角色已确认',
|
||||
errorMessage: '确认人工兑换角色失败',
|
||||
scope: '[admin/manual-redeem/:taskId/confirm-role]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminManualRedeemMutationResult
|
||||
return {
|
||||
action: 'manual_redeem_role_confirmed',
|
||||
targetType: 'task',
|
||||
targetId: String(result.task?.taskId || ''),
|
||||
data: {
|
||||
taskId: result.task?.taskId,
|
||||
taskNo: result.task?.taskNo,
|
||||
status: result.task?.status,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
|
||||
(req) => redeemAdminManualRedeemTask(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换任务已启动',
|
||||
errorMessage: '执行人工兑换失败',
|
||||
scope: '[admin/manual-redeem/:taskId/redeem]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminManualRedeemMutationResult
|
||||
return {
|
||||
action: 'manual_redeem_started',
|
||||
targetType: 'task',
|
||||
targetId: String(result.task?.taskId || ''),
|
||||
data: {
|
||||
taskId: result.task?.taskId,
|
||||
taskNo: result.task?.taskNo,
|
||||
status: result.task?.status,
|
||||
deliveryStatus: result.task?.deliveryStatus,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/manual-redeem/:taskId/close', createJsonHandler(
|
||||
(req) => closeAdminManualRedeemTask(
|
||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: '人工兑换任务已关闭',
|
||||
errorMessage: '关闭人工兑换任务失败',
|
||||
scope: '[admin/manual-redeem/:taskId/close]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminManualRedeemMutationResult
|
||||
return {
|
||||
action: 'manual_redeem_closed',
|
||||
targetType: 'task',
|
||||
targetId: String(result.task?.taskId || ''),
|
||||
data: {
|
||||
taskId: result.task?.taskId,
|
||||
taskNo: result.task?.taskNo,
|
||||
status: result.task?.status,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
export default router
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { requireAdminRoles } from "./shared.js";
|
||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.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();
|
||||
|
||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||
router.use("/platform-config", notificationsRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", ninetyoneRouter);
|
||||
router.use("/platform-config", cloudtentaclesRouter);
|
||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Router } from "express";
|
||||
|
||||
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";
|
||||
@@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js";
|
||||
const router = Router();
|
||||
|
||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||
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;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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;
|
||||
@@ -1,98 +0,0 @@
|
||||
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;
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { Request } from "express";
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
getAdminTaskDetail,
|
||||
getAdminTasks,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import {
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from "../../services/admin/write/kuaishou-cloud-actions.js";
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminRoles,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
AdminTaskRouteParams,
|
||||
AdminTaskRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function getTaskId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).taskId;
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/tasks",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取任务列表失败",
|
||||
scope: "[admin-kuaishou/tasks]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/tasks/:taskId",
|
||||
createJsonHandler(
|
||||
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取任务详情失败",
|
||||
scope: "[admin-kuaishou/tasks/:taskId]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
prepareAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "绑定资源已准备完成",
|
||||
errorMessage: "准备绑定资源失败",
|
||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]",
|
||||
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/refresh-role-info",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
refreshAdminTaskKuaishouCloudRoleInfo(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "角色信息已刷新",
|
||||
errorMessage: "刷新角色信息失败",
|
||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
||||
audit: (req, data) =>
|
||||
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/dispatch",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
dispatchAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "已完成绑定确认并发货",
|
||||
errorMessage: "执行发货失败",
|
||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
||||
|
||||
return {
|
||||
action: "task_kuaishou_cloud_dispatch",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/return-number",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "号码已退还",
|
||||
errorMessage: "退还号码失败",
|
||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]",
|
||||
audit: (req, data) =>
|
||||
buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function buildTaskAudit(action: string, req: Request, data: unknown) {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
|
||||
return {
|
||||
action,
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,42 +1,26 @@
|
||||
import type { Request } from "express";
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
confirmAdminTaskAssistedRole,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
regenerateAdminTaskClaimLink,
|
||||
redeemAdminTaskAssisted,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
releaseAdminTaskInventory,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
retryAdminTask,
|
||||
} from "../../services/admin/admin-write-service.js";
|
||||
import {
|
||||
getAdminTaskDetail,
|
||||
getAdminTasks,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js";
|
||||
import {
|
||||
createFileHandler,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from "../../services/admin/write/kuaishou-cloud-actions.js";
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminRoles,
|
||||
} from "./shared.js";
|
||||
import type { Request } from "express";
|
||||
import type {
|
||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
AdminTaskManualDispatchRouteBody,
|
||||
AdminTaskRouteParams,
|
||||
AdminTaskRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskBindingReleaseResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from "../../types/admin-write-models.js";
|
||||
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).taskId;
|
||||
}
|
||||
|
||||
function getBindingId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).bindingId;
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/tasks",
|
||||
createJsonHandler(
|
||||
@@ -73,239 +53,6 @@ router.get(
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/tasks/:taskId/screenshot",
|
||||
createFileHandler(
|
||||
(req) =>
|
||||
getAdminTaskScreenshotPathWithTencentFallback(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
errorMessage: "读取任务截图失败",
|
||||
scope: "[admin/tasks/:taskId/screenshot]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/retry",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler((req) => retryAdminTask(getTaskId(req)), {
|
||||
successMessage: "任务已重试",
|
||||
errorMessage: "重试任务失败",
|
||||
scope: "[admin/tasks/:taskId/retry]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/release-inventory",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), {
|
||||
successMessage: "库存凭据已释放",
|
||||
errorMessage: "释放库存凭据失败",
|
||||
scope: "[admin/tasks/:taskId/release-inventory]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_release_inventory",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
inventoryItemId: result.task.inventoryItemId,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/inventory-bindings/:bindingId/release",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)),
|
||||
{
|
||||
successMessage: "库存绑定已释放",
|
||||
errorMessage: "释放库存绑定失败",
|
||||
scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskBindingReleaseResponse;
|
||||
return {
|
||||
action: "task_release_inventory_binding",
|
||||
targetType: "task_inventory_binding",
|
||||
targetId: String(getBindingId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
bindingId: result.bindingId,
|
||||
inventoryItemId: result.inventoryItemId,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/regenerate-claim-link",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "领取链接已重新生成",
|
||||
errorMessage: "重新生成领取链接失败",
|
||||
scope: "[admin/tasks/:taskId/regenerate-claim-link]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_regenerate_claim_link",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
primaryClaimTokenId: result.task.primaryClaimTokenId,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/support-confirm-role",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "角色已确认",
|
||||
errorMessage: "客服确认角色失败",
|
||||
scope: "[admin/tasks/:taskId/support-confirm-role]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_support_confirm_role",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/support-redeem",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "兑换任务已启动",
|
||||
errorMessage: "客服发起兑换失败",
|
||||
scope: "[admin/tasks/:taskId/support-redeem]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_support_redeem",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/close",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler((req) => closeAdminTask(getTaskId(req)), {
|
||||
successMessage: "任务已关闭",
|
||||
errorMessage: "关闭任务失败",
|
||||
scope: "[admin/tasks/:taskId/close]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_closed",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/mark-manual-review",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), {
|
||||
successMessage: "任务已转人工处理",
|
||||
errorMessage: "标记人工处理失败",
|
||||
scope: "[admin/tasks/:taskId/mark-manual-review]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_mark_manual_review",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/complete-manual-dispatch",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
completeAdminTaskManualDispatch(
|
||||
getTaskId(req),
|
||||
req.body as AdminTaskManualDispatchRouteBody,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "人工履约结果已回写",
|
||||
errorMessage: "回写人工履约结果失败",
|
||||
scope: "[admin/tasks/:taskId/complete-manual-dispatch]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskManualDispatchResponse;
|
||||
return {
|
||||
action: "task_complete_manual_dispatch",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
outcome: result.outcome,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
resultCode: result.task.resultCode,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
@@ -319,19 +66,7 @@ router.post(
|
||||
successMessage: "绑定资源已准备完成",
|
||||
errorMessage: "准备绑定资源失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_prepare",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -349,19 +84,8 @@ router.post(
|
||||
successMessage: "角色信息已刷新",
|
||||
errorMessage: "刷新角色信息失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_role_info_refreshed",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: (req, data) =>
|
||||
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -383,6 +107,7 @@ router.post(
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
||||
|
||||
return {
|
||||
action: "task_kuaishou_cloud_dispatch",
|
||||
targetType: "task",
|
||||
@@ -413,21 +138,25 @@ router.post(
|
||||
successMessage: "号码已退还",
|
||||
errorMessage: "退还号码失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_return_number",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: (req, data) =>
|
||||
buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function buildTaskAudit(action: string, req: Request, data: unknown) {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
|
||||
return {
|
||||
action,
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { createHttpError } from "../../utils/http.js";
|
||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||
import type {
|
||||
AdminWebhookEventRouteParams,
|
||||
AdminWebhookEventRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
|
||||
|
||||
router.get(
|
||||
"/webhook-events",
|
||||
createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 列表失败",
|
||||
scope: "[admin-kuaishou/webhook-events]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/webhook-events/:eventId",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminWebhookEventDetail(
|
||||
(req.params as AdminWebhookEventRouteParams).eventId
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 详情失败",
|
||||
scope: "[admin-kuaishou/webhook-events/:eventId]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/webhook-events/:eventId/replay",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
() => {
|
||||
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
|
||||
statusCode: 409,
|
||||
errorCode: "admin_webhook_replay_disabled",
|
||||
});
|
||||
},
|
||||
{
|
||||
successMessage: "Webhook 已重放",
|
||||
errorMessage: "重放 webhook 失败",
|
||||
scope: "[admin-kuaishou/webhook-events/:eventId/replay]",
|
||||
audit: (req, data) => {
|
||||
return {
|
||||
action: "webhook_replayed",
|
||||
targetType: "webhook_event",
|
||||
targetId: String(
|
||||
(req.params as AdminWebhookEventRouteParams).eventId
|
||||
),
|
||||
data: {
|
||||
disabled: true,
|
||||
result: data,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,60 +1,76 @@
|
||||
import { Router } from 'express'
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
replayAdminWebhookEvent,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { createHttpError } from "../../utils/http.js";
|
||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||
import type {
|
||||
AdminWebhookEventRouteParams,
|
||||
AdminWebhookEventRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.use('/webhook-events', requireAdminRoles(['admin', 'operator']))
|
||||
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
|
||||
|
||||
router.get('/webhook-events', createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 列表失败',
|
||||
scope: '[admin/webhook-events]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/webhook-events",
|
||||
createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 列表失败",
|
||||
scope: "[admin/webhook-events]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/webhook-events/:eventId', createJsonHandler(
|
||||
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 详情失败',
|
||||
scope: '[admin/webhook-events/:eventId]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/webhook-events/:eventId",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminWebhookEventDetail(
|
||||
(req.params as AdminWebhookEventRouteParams).eventId
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 详情失败",
|
||||
scope: "[admin/webhook-events/:eventId]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'Webhook 已重放',
|
||||
errorMessage: '重放 webhook 失败',
|
||||
scope: '[admin/webhook-events/:eventId/replay]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminWebhookReplayResponse
|
||||
return {
|
||||
action: 'webhook_replayed',
|
||||
targetType: 'webhook_event',
|
||||
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
data: {
|
||||
eventId: result.eventId,
|
||||
replayed: result.replayed,
|
||||
},
|
||||
}
|
||||
router.post(
|
||||
"/webhook-events/:eventId/replay",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
() => {
|
||||
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
|
||||
statusCode: 409,
|
||||
errorCode: "admin_webhook_replay_disabled",
|
||||
});
|
||||
},
|
||||
},
|
||||
))
|
||||
{
|
||||
successMessage: "Webhook 已重放",
|
||||
errorMessage: "重放 webhook 失败",
|
||||
scope: "[admin/webhook-events/:eventId/replay]",
|
||||
audit: (req, data) => {
|
||||
return {
|
||||
action: "webhook_replayed",
|
||||
targetType: "webhook_event",
|
||||
targetId: String(
|
||||
(req.params as AdminWebhookEventRouteParams).eventId
|
||||
),
|
||||
data: {
|
||||
disabled: true,
|
||||
result: data,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
getKuaishouCloudClaimDetail,
|
||||
getKuaishouCloudClaimGuideAssetPath,
|
||||
redeemKuaishouCloudClaim,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from "../services/claim/kuaishou-cloud-claim-service.js";
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
} from "../utils/http.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/:token",
|
||||
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||
errorMessage: "查询快手领取详情失败",
|
||||
scope: "[claims-kuaishou/:token]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/verify-ticket",
|
||||
createRouteHandler(
|
||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||
{
|
||||
successMessage: "核销码校验成功",
|
||||
errorMessage: "校验核销码失败",
|
||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/confirm-role",
|
||||
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: "角色已确认",
|
||||
errorMessage: "确认角色失败",
|
||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/redeem",
|
||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||
successMessage: "兑换请求已提交",
|
||||
errorMessage: "兑换失败",
|
||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]",
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/assets/kuaishou-cloud/:filename",
|
||||
createRouteFileHandler(
|
||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||
{
|
||||
errorMessage: "读取指引图片失败",
|
||||
scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,97 +1,71 @@
|
||||
import { Router } from 'express'
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
closeClaimSession,
|
||||
confirmClaimRole,
|
||||
createClaimSession,
|
||||
getClaimDetail,
|
||||
getClaimScreenshotPath,
|
||||
getClaimSessionSummary,
|
||||
reloadClaimSession,
|
||||
redeemClaimTask,
|
||||
} from '../services/claim/claim-session-service.js'
|
||||
import {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
getKuaishouCloudClaimDetail,
|
||||
getKuaishouCloudClaimGuideAssetPath,
|
||||
redeemKuaishouCloudClaim,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
} from "../services/claim/kuaishou-cloud-claim-service.js";
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
buildSuccessPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
sendRouteError,
|
||||
} from '../utils/http.js'
|
||||
} from "../utils/http.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.get('/:token', createRouteHandler(
|
||||
(req) => getClaimDetail(req.params.token),
|
||||
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' },
|
||||
))
|
||||
router.get(
|
||||
"/:token",
|
||||
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||
errorMessage: "查询快手领取详情失败",
|
||||
scope: "[claims/:token]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/:token/session', createRouteHandler(
|
||||
(req) => createClaimSession(req.params.token, req.body),
|
||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/verify-ticket",
|
||||
createRouteHandler(
|
||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||
{
|
||||
successMessage: "核销码校验成功",
|
||||
errorMessage: "校验核销码失败",
|
||||
scope: "[claims/:token/kuaishou-cloud/verify-ticket]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/:token/session/summary', createRouteHandler(
|
||||
(req) => getClaimSessionSummary(req.params.token),
|
||||
{ errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' },
|
||||
))
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/confirm-role",
|
||||
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: "角色已确认",
|
||||
errorMessage: "确认角色失败",
|
||||
scope: "[claims/:token/kuaishou-cloud/confirm-role]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/:token/session/refresh', async (req, res) => {
|
||||
try {
|
||||
const data = await reloadClaimSession(req.params.token)
|
||||
res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]')
|
||||
}
|
||||
})
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/redeem",
|
||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||
successMessage: "兑换请求已提交",
|
||||
errorMessage: "兑换失败",
|
||||
scope: "[claims/:token/kuaishou-cloud/redeem]",
|
||||
})
|
||||
);
|
||||
|
||||
router.delete('/:token/session', createRouteHandler(
|
||||
(req) => closeClaimSession(req.params.token),
|
||||
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
|
||||
router.post('/:token/confirm-role', createRouteHandler(
|
||||
(req) => confirmClaimRole(req.params.token),
|
||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' },
|
||||
))
|
||||
|
||||
router.post('/:token/redeem', createRouteHandler(
|
||||
(req) => redeemClaimTask(req.params.token),
|
||||
{ successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' },
|
||||
))
|
||||
|
||||
router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler(
|
||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||
{ successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' },
|
||||
))
|
||||
|
||||
router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler(
|
||||
(req) => confirmKuaishouCloudClaimRole(req.params.token),
|
||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' },
|
||||
))
|
||||
|
||||
router.post('/:token/kuaishou-cloud/redeem', createRouteHandler(
|
||||
(req) => redeemKuaishouCloudClaim(req.params.token),
|
||||
{ successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' },
|
||||
))
|
||||
|
||||
router.get('/:token/screenshot', createRouteFileHandler(
|
||||
(req) => getClaimScreenshotPath(req.params.token),
|
||||
{ errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' },
|
||||
))
|
||||
|
||||
router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler(
|
||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||
{ errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' },
|
||||
))
|
||||
router.get(
|
||||
"/assets/kuaishou-cloud/:filename",
|
||||
createRouteFileHandler(
|
||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||
{
|
||||
errorMessage: "读取指引图片失败",
|
||||
scope: "[claims/assets/kuaishou-cloud/:filename]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeTencentBrowserSession,
|
||||
createTencentBrowserSession,
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionSummary,
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
reloadTencentBrowserSession,
|
||||
redeemTencentBrowserSession,
|
||||
} from '../services/session/session.js'
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
buildSuccessPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
sendRouteError,
|
||||
} from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/browser/session', createRouteHandler(
|
||||
(req) => createTencentBrowserSession(req.body),
|
||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' },
|
||||
))
|
||||
|
||||
router.get('/browser/session/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const data = await getTencentBrowserSession(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browser/session/:sessionId/summary', async (req, res) => {
|
||||
try {
|
||||
const data = await getTencentBrowserSessionSummary(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/browser/session/:sessionId/refresh', async (req, res) => {
|
||||
try {
|
||||
const data = await reloadTencentBrowserSession(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/browser/session/:sessionId/redeem', async (req, res) => {
|
||||
try {
|
||||
const data = await redeemTencentBrowserSession(req.params.sessionId, req.body)
|
||||
res.json(buildSuccessPayload(data, data.notice || '兑换完成'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler(
|
||||
(req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId),
|
||||
{ errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' },
|
||||
))
|
||||
|
||||
router.delete('/browser/session/:sessionId', createRouteHandler(
|
||||
(req) => closeTencentBrowserSession(req.params.sessionId),
|
||||
{ successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' },
|
||||
))
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
|
||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
||||
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/agiso/trade', async (req, res) => {
|
||||
const requestId = createRequestId('wh')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
ip: req.ip,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
try {
|
||||
const data = await processAgisoTradeWebhook({
|
||||
requestId,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result: data,
|
||||
})
|
||||
|
||||
res.json(buildSuccessPayload(data, 'success'))
|
||||
} catch (error) {
|
||||
logWebhook(
|
||||
'[webhooks/agiso/trade]',
|
||||
'Agiso webhook 处理失败',
|
||||
{
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
},
|
||||
{ level: 'error' },
|
||||
)
|
||||
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
|
||||
}
|
||||
})
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,434 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import {
|
||||
closeClaimSessionForAdminTask,
|
||||
createClaimSessionForAdminTask,
|
||||
getClaimDetailForAdminTask,
|
||||
getClaimSessionSummaryForAdminTask,
|
||||
reloadClaimSessionForAdminTask,
|
||||
} from '../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
|
||||
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
|
||||
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../repositories/task-event-repo.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
|
||||
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../types/admin-read-inputs.js'
|
||||
import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js'
|
||||
import type { TaskRow } from '../../types/repository-rows.js'
|
||||
|
||||
const MANUAL_PROVIDER = 'manual'
|
||||
const MANUAL_PLATFORM = 'manual_redeem'
|
||||
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
|
||||
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function createAdminManualRedeemTask(
|
||||
payload: AdminManualRedeemCreateInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const proofValue = normalizeManualProofValue(payload.proofValue)
|
||||
const skuCode = String(payload.skuCode || '').trim()
|
||||
const skuName = String(payload.skuName || '').trim() || skuCode
|
||||
const remark = String(payload.remark || '').trim()
|
||||
|
||||
if (!proofValue) {
|
||||
throw createHttpError('请先输入唯一凭据', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_manual_redeem_missing_proof',
|
||||
})
|
||||
}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError('请先选择内部履约 SKU', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_manual_redeem_missing_sku',
|
||||
})
|
||||
}
|
||||
|
||||
const existingOrder = await findOrderByPlatformOrderId({
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
platformOrderId: proofValue,
|
||||
})
|
||||
|
||||
if (existingOrder) {
|
||||
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||
})
|
||||
}
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
|
||||
if (!profile) {
|
||||
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_profile_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const requirements = await listFulfillmentProfileRequirements(profile.id)
|
||||
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
|
||||
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
|
||||
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
|
||||
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
|
||||
skuCode,
|
||||
credentialType,
|
||||
viewerContext.allowedInventoryGroupCodes,
|
||||
)
|
||||
|
||||
if (!availableInventory) {
|
||||
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
|
||||
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_unbound',
|
||||
})
|
||||
}
|
||||
|
||||
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_inventory_unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const manualContext = {
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
proofValue,
|
||||
remark,
|
||||
createdAt: now,
|
||||
createdBy: session
|
||||
? {
|
||||
userId: Number(session.userId || 0) || 0,
|
||||
username: String(session.username || '').trim(),
|
||||
role: String(session.role || '').trim(),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
let order
|
||||
try {
|
||||
order = await createOrder({
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
shopName: '人工兑换',
|
||||
platformOrderId: proofValue,
|
||||
orderStatus: 'manual_created',
|
||||
payStatus: 'paid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
rawPayloadJson: JSON.stringify({
|
||||
manualRedeem: manualContext,
|
||||
}),
|
||||
paidAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
} catch (error) {
|
||||
if (String(error?.code || '') === '23505') {
|
||||
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_duplicate_proof',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('人工兑换订单创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_order_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const orderItems = await replaceOrderItems(order.id, [
|
||||
{
|
||||
skuCode,
|
||||
skuName,
|
||||
quantity: 1,
|
||||
specJson: JSON.stringify({
|
||||
title: skuName,
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
}),
|
||||
itemSnapshotJson: JSON.stringify({
|
||||
manualRedeem: manualContext,
|
||||
skuCode,
|
||||
skuName,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
|
||||
const orderItem = orderItems[0]
|
||||
if (!orderItem) {
|
||||
throw createHttpError('人工兑换订单商品创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_order_item_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const createdTask = await createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: orderItem.id,
|
||||
unitIndex: 1,
|
||||
provider: MANUAL_PROVIDER,
|
||||
platform: MANUAL_PLATFORM,
|
||||
shopId: '',
|
||||
shopName: '人工兑换',
|
||||
platformOrderId: proofValue,
|
||||
taskNo: randomId('MR'),
|
||||
profileId: Number(profile.id),
|
||||
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
|
||||
taskStatus: 'paid',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
resultCode: '',
|
||||
resultMessage: '',
|
||||
claimToken: '',
|
||||
claimExpiresAt: null,
|
||||
automationMode: 'manual',
|
||||
requiresClaim: true,
|
||||
userActionStatus: 'pending_claim',
|
||||
attemptCount: 0,
|
||||
lastError: '',
|
||||
contextJson: JSON.stringify({
|
||||
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
|
||||
profileName: String(profile.name || ''),
|
||||
skuCode,
|
||||
skuName,
|
||||
inventorySkuCode: skuCode,
|
||||
primaryRequirement: primaryRequirement
|
||||
? {
|
||||
roleKey,
|
||||
credentialType,
|
||||
}
|
||||
: null,
|
||||
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
|
||||
manualRedeem: manualContext,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!createdTask) {
|
||||
throw createHttpError('人工兑换任务创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_manual_redeem_task_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const reservedInventory = await reserveInventoryForTask({
|
||||
skuCode,
|
||||
taskId: createdTask.id,
|
||||
credentialType,
|
||||
roleKey,
|
||||
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
||||
})
|
||||
|
||||
if (!reservedInventory) {
|
||||
await updateTask(createdTask.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '库存不足,无法为人工兑换任务预占库存',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_inventory_race_lost',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(createdTask.id)
|
||||
await updateTask(createdTask.id, {
|
||||
task_status: 'link_generated',
|
||||
inventory_status: 'reserved',
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
user_action_status: 'pending_claim',
|
||||
last_error: '',
|
||||
context_json: JSON.stringify({
|
||||
...parseTaskContext(createdTask),
|
||||
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
|
||||
}),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
|
||||
sourceType: MANUAL_SOURCE_TYPE,
|
||||
proofValue,
|
||||
skuCode,
|
||||
skuName,
|
||||
inventoryItemId: Number(reservedInventory.id || 0) || null,
|
||||
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
|
||||
createdBy: manualContext.createdBy,
|
||||
remark,
|
||||
}, nowIso())
|
||||
|
||||
return getAdminManualRedeemDetail(createdTask.id, session)
|
||||
}
|
||||
|
||||
export async function getAdminManualRedeemDetail(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await getClaimDetailForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function createAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
payload: { loginType?: string, forceRecreate?: boolean } = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await createClaimSessionForAdminTask(task.id, payload)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function getAdminManualRedeemSessionSummary(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await getClaimSessionSummaryForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function reloadAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await reloadClaimSessionForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function closeAdminManualRedeemSession(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
const detail = await closeClaimSessionForAdminTask(task.id)
|
||||
return decorateManualRedeemDetail(task, detail)
|
||||
}
|
||||
|
||||
export async function closeAdminManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await closeAdminTask(task.id)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
export async function confirmAdminManualRedeemRole(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await confirmAdminTaskAssistedRole(task.id, session)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
export async function redeemAdminManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<JsonObject> {
|
||||
const task = await getRequiredManualRedeemTask(taskId, session)
|
||||
await redeemAdminTaskAssisted(task.id, session)
|
||||
return getAdminManualRedeemDetail(task.id, session)
|
||||
}
|
||||
|
||||
async function getRequiredManualRedeemTask(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<TaskRow> {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('人工兑换任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_manual_redeem_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
|
||||
|
||||
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
|
||||
throw createHttpError('当前任务不是人工兑换任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_manual_redeem_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
if (viewerContext.restrictInventoryGroups) {
|
||||
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
|
||||
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_unbound',
|
||||
})
|
||||
}
|
||||
|
||||
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
|
||||
|
||||
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
|
||||
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_manual_redeem_inventory_group_denied',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject {
|
||||
const taskContext = parseTaskContext(task)
|
||||
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
|
||||
? taskContext.manualRedeem
|
||||
: {}
|
||||
|
||||
return {
|
||||
...detail,
|
||||
claimUrl: '',
|
||||
manualRequest: {
|
||||
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
|
||||
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
|
||||
remark: String(manualRedeem.remark || '').trim(),
|
||||
},
|
||||
result: detail.result
|
||||
? {
|
||||
...detail.result,
|
||||
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManualProofValue(value: unknown): string {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
import {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
? order.raw_payload_json
|
||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
export {
|
||||
getRequiredInventoryItem,
|
||||
mapAdminInventoryListItem,
|
||||
} from './admin-inventory-read-helpers.js'
|
||||
export {
|
||||
mapAdminOrderListItem,
|
||||
summarizeOrderItems,
|
||||
} from './admin-order-read-helpers.js'
|
||||
export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
|
||||
export {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createEmptyTaskBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
getTaskBindingSummary,
|
||||
getTaskBindingSummaryMap,
|
||||
mapAdminTaskEvent,
|
||||
mapAdminTaskInventoryBinding,
|
||||
mapAdminTaskListItem,
|
||||
mapAdminTaskSummary,
|
||||
mapTaskActionPayload,
|
||||
} from './admin-task-read-helpers.js'
|
||||
|
||||
export {
|
||||
canRegenerateClaimLinkForViewer,
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerRedeemAssistedTask,
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
mapManualDispatchContext,
|
||||
mapRedeemResolutionContext,
|
||||
parseTaskContext,
|
||||
parseTaskState,
|
||||
resolveOrderItemDeliveryMode,
|
||||
resolveOrderItemTitle,
|
||||
resolveAdminTaskScreenshotUrl,
|
||||
resolveDisplayShopName,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
export {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
getAdminOrderDetail,
|
||||
getAdminOrders,
|
||||
getAdminTaskDetail,
|
||||
getAdminTasks,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from './admin-read-service.js'
|
||||
|
||||
export {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
confirmAdminTaskAssistedRole,
|
||||
createAdminInventoryItem,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
redeemAdminTaskAssisted,
|
||||
regenerateAdminTaskClaimLink,
|
||||
releaseAdminInventoryItem,
|
||||
releaseAdminTaskInventory,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
replayAdminWebhookEvent,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
retryAdminTask,
|
||||
} from './admin-write-service.js'
|
||||
|
||||
export {
|
||||
getAdminAgisoShopConfigs,
|
||||
getAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
updateAdminAgisoShopConfigs,
|
||||
updateAdminFulfillmentBindingConfigs,
|
||||
} from './platform-config/service.js'
|
||||
|
||||
export { getAdminDashboardSummary } from './admin-dashboard-service.js'
|
||||
export { getAdminMessageDeliveries } from './admin-message-delivery-service.js'
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminAgisoAutoDeliveryStatus,
|
||||
AdminTaskBindingSummary,
|
||||
AdminTaskListItem,
|
||||
} from '../../types/admin-read-models.js'
|
||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||
import type {
|
||||
OrderRow,
|
||||
TaskEventRow,
|
||||
TaskInventoryBindingRow,
|
||||
TaskInventoryBindingSummaryRow,
|
||||
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
}
|
||||
|
||||
export function mapAdminTaskSummary(
|
||||
task: TaskRow,
|
||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||
): JsonRecord {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): AdminTaskListItem {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrderAgisoAutoDeliverySummary(
|
||||
order: Partial<OrderRow> | null | undefined,
|
||||
tasks: TaskRow[] = [],
|
||||
): OrderAgisoAutoDeliverySummary | null {
|
||||
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
|
||||
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
|
||||
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
|
||||
|
||||
if (!autoDelivery) {
|
||||
return best
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...autoDelivery,
|
||||
sourceTaskId: Number(task.id || 0) || null,
|
||||
sourceTaskNo: String(task.task_no || '').trim(),
|
||||
}
|
||||
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
|
||||
const bestTime = Date.parse(String(best?.updatedAt || ''))
|
||||
|
||||
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return best
|
||||
}, null)
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
...latest,
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
|
||||
trigger: '',
|
||||
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: 0,
|
||||
errorMessage: '',
|
||||
requestId: '',
|
||||
updatedAt: null,
|
||||
sourceTaskId: null,
|
||||
sourceTaskNo: '',
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
|
||||
if (!task || !binding) {
|
||||
return false
|
||||
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
||||
}
|
||||
|
||||
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as JsonRecord
|
||||
return {
|
||||
status: String(record.status || '').trim(),
|
||||
trigger: String(record.trigger || '').trim(),
|
||||
reason: String(record.reason || '').trim(),
|
||||
platformOrderId: String(record.platformOrderId || '').trim(),
|
||||
responseStatus: Number(record.responseStatus || 0),
|
||||
errorMessage: String(record.errorMessage || '').trim(),
|
||||
requestId: String(record.requestId || '').trim(),
|
||||
updatedAt: record.updatedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { getRequiredTask } from './admin-task-read-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../types/admin-read-inputs.js'
|
||||
|
||||
export async function getAdminTaskScreenshotPathWithTencentFallback(
|
||||
taskId: AdminEntityIdInput,
|
||||
_session: AdminViewerSessionInput | null = null,
|
||||
): Promise<string> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (task.screenshot_path) {
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
throw createHttpError('当前任务还没有可查看截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
||||
|
||||
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
|
||||
|
||||
const mapped = {
|
||||
eventId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
provider: item.provider || '',
|
||||
platform: item.platform,
|
||||
platformRaw: pickFirstNonEmpty([
|
||||
query.fromPlatform,
|
||||
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
|
||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
||||
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
|
||||
buyerId: pickFirstNonEmpty([
|
||||
payload.buyer_id,
|
||||
payload.buyerId,
|
||||
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
|
||||
}
|
||||
|
||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
||||
return extractAgisoTradePayload(body)
|
||||
const candidates = [
|
||||
body.data,
|
||||
body.Data,
|
||||
body.payload,
|
||||
body.Payload,
|
||||
body.message,
|
||||
body.Message,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
return normalizeRecord(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
|
||||
const fromPayload = pickFirstNonEmpty([
|
||||
payload.biz_order_id,
|
||||
payload.bizOrderId,
|
||||
payload.BizOrderId,
|
||||
payload.tid,
|
||||
payload.Tid,
|
||||
payload.order_id,
|
||||
payload.orderId,
|
||||
payload.OrderId,
|
||||
payload.oid,
|
||||
payload.Oid,
|
||||
])
|
||||
|
||||
if (fromPayload) {
|
||||
return fromPayload
|
||||
}
|
||||
|
||||
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
|
||||
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
|
||||
}
|
||||
|
||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { closeAdminTaskWithDeps } from './admin-write-service.js'
|
||||
|
||||
test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => {
|
||||
const calls = {
|
||||
updateToken: [],
|
||||
releaseReserved: [],
|
||||
closeSession: [],
|
||||
updateTask: [],
|
||||
createEvent: [],
|
||||
}
|
||||
const task = {
|
||||
id: 30,
|
||||
task_status: 'link_generated',
|
||||
delivery_status: 'pending',
|
||||
inventory_status: 'reserved',
|
||||
user_action_status: 'pending_claim',
|
||||
browser_session_id: 'browser-session-30',
|
||||
primary_claim_token_id: 9,
|
||||
claim_expires_at: '2026-04-15T10:00:00.000Z',
|
||||
last_error: '',
|
||||
updated_at: '2026-04-14T10:00:00.000Z',
|
||||
}
|
||||
const now = '2026-04-14T10:16:18.000Z'
|
||||
|
||||
const result = await closeAdminTaskWithDeps(task.id, {
|
||||
getRequiredTask: async () => task,
|
||||
listTaskInventoryBindingsByTaskId: async () => ([
|
||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
||||
{ inventory_item_id: 18, binding_status: 'released' },
|
||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
||||
]),
|
||||
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
|
||||
calls.releaseReserved.push({ inventoryItemId, updatedAt })
|
||||
return { id: inventoryItemId }
|
||||
},
|
||||
updateClaimToken: async (tokenId, patch) => {
|
||||
calls.updateToken.push({ tokenId, patch })
|
||||
return { id: tokenId, ...patch }
|
||||
},
|
||||
closeTencentBrowserSession: async (sessionId) => {
|
||||
calls.closeSession.push(sessionId)
|
||||
return { sessionId, closed: true }
|
||||
},
|
||||
updateTask: async (taskId, patch) => {
|
||||
calls.updateTask.push({ taskId, patch })
|
||||
return { ...task, ...patch }
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
calls.createEvent.push({ taskId, eventType, payload, createdAt })
|
||||
return null
|
||||
},
|
||||
nowIso: () => now,
|
||||
})
|
||||
|
||||
assert.equal(calls.updateToken.length, 1)
|
||||
assert.deepEqual(calls.updateToken[0], {
|
||||
tokenId: 9,
|
||||
patch: {
|
||||
status: 'revoked',
|
||||
expired_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
assert.deepEqual(calls.releaseReserved, [
|
||||
{ inventoryItemId: 17, updatedAt: now },
|
||||
])
|
||||
assert.deepEqual(calls.closeSession, ['browser-session-30'])
|
||||
assert.equal(calls.updateTask.length, 1)
|
||||
assert.equal(calls.updateTask[0].patch.task_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.delivery_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.inventory_status, 'pending')
|
||||
assert.equal(calls.updateTask[0].patch.user_action_status, 'closed')
|
||||
assert.equal(calls.updateTask[0].patch.browser_session_id, '')
|
||||
assert.equal(calls.updateTask[0].patch.claim_expires_at, now)
|
||||
assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/)
|
||||
assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/)
|
||||
assert.equal(calls.createEvent.length, 1)
|
||||
assert.deepEqual(calls.createEvent[0], {
|
||||
taskId: 30,
|
||||
eventType: 'task_closed',
|
||||
payload: {
|
||||
claimTokenRevoked: true,
|
||||
releasedInventoryItemIds: [17],
|
||||
releasedInventoryCount: 1,
|
||||
browserSessionClosed: true,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
assert.equal(result.task.status, 'closed')
|
||||
})
|
||||
|
||||
test('closeAdminTaskWithDeps rejects redeemed tasks', async () => {
|
||||
await assert.rejects(
|
||||
() => closeAdminTaskWithDeps(99, {
|
||||
getRequiredTask: async () => ({
|
||||
id: 99,
|
||||
task_status: 'redeemed',
|
||||
}),
|
||||
}),
|
||||
/已兑换任务不能关闭/,
|
||||
)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
export {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
releaseAdminInventoryItem,
|
||||
invalidateAdminInventoryItem,
|
||||
} from './write/inventory.js'
|
||||
|
||||
export {
|
||||
replayAdminWebhookEvent,
|
||||
} from './write/webhook-events.js'
|
||||
|
||||
export {
|
||||
releaseAdminTaskInventory,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
regenerateAdminTaskClaimLink,
|
||||
confirmAdminTaskAssistedRole,
|
||||
redeemAdminTaskAssisted,
|
||||
closeAdminTask,
|
||||
closeAdminTaskWithDeps,
|
||||
markAdminTaskManualReview,
|
||||
retryAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
} from './write/task-actions.js'
|
||||
|
||||
export {
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from './write/kuaishou-cloud-actions.js'
|
||||
@@ -1,65 +0,0 @@
|
||||
import { query } from '../../../db/client.js'
|
||||
import {
|
||||
getAgisoMessagingDefaults,
|
||||
getAgisoShopConfigMap,
|
||||
getAgisoShopsFilePath,
|
||||
saveAgisoMessagingConfig,
|
||||
} from '../../platforms/agiso/shop-config-service.js'
|
||||
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
|
||||
import {
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
} from './domain.js'
|
||||
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminAgisoShopConfigs() {
|
||||
const configMap = getAgisoShopConfigMap()
|
||||
const defaults = getAgisoMessagingDefaults()
|
||||
const rowsResult = await query(
|
||||
`
|
||||
SELECT
|
||||
shop_id,
|
||||
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
||||
MAX(created_at) AS latest_seen_at,
|
||||
COUNT(*)::int AS webhook_event_count
|
||||
FROM webhook_events
|
||||
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
||||
GROUP BY shop_id
|
||||
ORDER BY latest_seen_at DESC, shop_id DESC
|
||||
`,
|
||||
)
|
||||
const rows = rowsResult.rows
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(defaults),
|
||||
shops: Object.entries(configMap)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
observedShops: rows.map((row) => ({
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
detectedShopName: String(row.detected_shop_name || '').trim(),
|
||||
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
||||
latestSeenAt: row.latest_seen_at || null,
|
||||
webhookEventCount: Number(row.webhook_event_count || 0),
|
||||
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
|
||||
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
|
||||
currentDefaults: getAgisoMessagingDefaults(),
|
||||
currentMap: getAgisoShopConfigMap(),
|
||||
}))
|
||||
|
||||
return {
|
||||
filePath: getAgisoShopsFilePath(),
|
||||
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
|
||||
shops: Object.entries(saved.shops)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function normalizeAgisoMessageTemplate(value: unknown): string {
|
||||
return String(value || "")
|
||||
.replaceAll("\\r\\n", "\n")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll("\r\n", "\n");
|
||||
}
|
||||
@@ -2,101 +2,15 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
applyOptionalStringField,
|
||||
findMatchingObservedBinding,
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
mapAdminFulfillmentBindingConfigItem,
|
||||
mapAdminObservedProductItem,
|
||||
matchesObservedProduct,
|
||||
} from './domain.js'
|
||||
|
||||
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoMessagingDefaults({
|
||||
messageTemplate: ' hello\\nworld ',
|
||||
autoDeliveryMessageTemplate: ' done\\nnow ',
|
||||
}),
|
||||
{
|
||||
messageTemplate: 'hello\nworld',
|
||||
autoDeliveryMessageTemplate: 'done\nnow',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoShopConfigItem('1001', {
|
||||
shopName: ' 店铺A ',
|
||||
accessToken: 'abcdef1234567890',
|
||||
enabled: true,
|
||||
messageTemplate: ' hi\\nall ',
|
||||
autoDeliveryMessageTemplate: ' ok ',
|
||||
appSecret: 'secret-1',
|
||||
apiVersion: ' v2 ',
|
||||
sendMessageEndpoint: ' /send ',
|
||||
}),
|
||||
{
|
||||
shopId: '1001',
|
||||
shopName: '店铺A',
|
||||
accessToken: 'abcdef1234567890',
|
||||
accessTokenMasked: 'abcdef****567890',
|
||||
enabled: true,
|
||||
messageTemplate: 'hi\nall',
|
||||
autoDeliveryMessageTemplate: 'ok',
|
||||
appSecretConfigured: true,
|
||||
apiVersion: 'v2',
|
||||
sendMessageEndpoint: '/send',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOptionalStringField updates and clears normalized template fields', () => {
|
||||
const target = { messageTemplate: 'old' }
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
|
||||
assert.deepEqual(target, { messageTemplate: 'next\nline' })
|
||||
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
|
||||
assert.deepEqual(target, {})
|
||||
})
|
||||
|
||||
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminFulfillmentBindingConfigItem({
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: ' sku-1 ',
|
||||
priority: '80',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: '',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
config: {},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('matchesObservedProduct honors provider platform shop and external fields', () => {
|
||||
const binding = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-2',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
const bindings = [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
skuName: 'SKU 1',
|
||||
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
},
|
||||
]
|
||||
const observed = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
|
||||
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
||||
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
|
||||
@@ -1,70 +1,5 @@
|
||||
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
|
||||
import { maskSecret } from './mappers.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) {
|
||||
return {
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(defaults.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) {
|
||||
return {
|
||||
shopId,
|
||||
shopName: String(config.shopName || '').trim(),
|
||||
accessToken: String(config.accessToken || '').trim(),
|
||||
accessTokenMasked: maskSecret(config.accessToken),
|
||||
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
|
||||
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
|
||||
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
|
||||
String(config.autoDeliveryMessageTemplate || '').trim(),
|
||||
),
|
||||
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
|
||||
apiVersion: String(config.apiVersion || '').trim(),
|
||||
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) {
|
||||
if (!source || typeof source[key] !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const value = String(source[key] || '').trim()
|
||||
if (value) {
|
||||
target[key] = normalizeAgisoMessageTemplate(value)
|
||||
return
|
||||
}
|
||||
|
||||
delete target[key]
|
||||
}
|
||||
|
||||
export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) {
|
||||
const match = item?.match || {}
|
||||
return {
|
||||
provider: String(item?.provider || '').trim(),
|
||||
platform: String(item?.platform || '').trim(),
|
||||
shopId: String(item?.shopId || '').trim(),
|
||||
shopName: String(item?.shopName || '').trim(),
|
||||
skuCode: String(item?.skuCode || '').trim(),
|
||||
skuName: String(item?.skuName || '').trim(),
|
||||
profileKey: String(item?.profileKey || '').trim(),
|
||||
enabled: item?.enabled !== false,
|
||||
priority: Number(item?.priority || 100),
|
||||
config: item?.config || {},
|
||||
match: {
|
||||
externalSkuCode: String(match.externalSkuCode || '').trim(),
|
||||
externalItemId: String(match.externalItemId || '').trim(),
|
||||
externalSkuName: String(match.externalSkuName || '').trim(),
|
||||
config: match.config || {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
|
||||
const provider = String(binding?.provider || '').trim()
|
||||
const platform = String(binding?.platform || '').trim()
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js'
|
||||
import {
|
||||
filterLegacyOrderFulfillmentBindings,
|
||||
getOrderFulfillmentBindingsFilePath,
|
||||
getLegacyOrderFulfillmentBindingConfigs,
|
||||
saveOrderFulfillmentBindingConfigs,
|
||||
} from '../../order/fulfillment-binding-config-service.js'
|
||||
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
import { listAdminObservedProducts } from './observed-products.js'
|
||||
import { validateAdminFulfillmentBindingConfigs } from './validation.js'
|
||||
import { mapAdminFulfillmentBindingConfigItem } from './domain.js'
|
||||
|
||||
import type {
|
||||
AdminFulfillmentBindingConfigSaveInput,
|
||||
AdminFulfillmentBindingLookupInput,
|
||||
} from '../../../types/admin-write-inputs.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminFulfillmentBindingConfigs() {
|
||||
const bindings = getLegacyOrderFulfillmentBindingConfigs()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
|
||||
observedProducts: await listAdminObservedProducts(bindings),
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupAdminFulfillmentBindingOrder(
|
||||
payload: AdminFulfillmentBindingLookupInput = {},
|
||||
) {
|
||||
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
|
||||
|
||||
const detailResult = await enrichAgisoXianyuTradeOrder({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: '',
|
||||
platformOrderId,
|
||||
orderStatus: 'created',
|
||||
payStatus: 'unpaid',
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: 0,
|
||||
currency: 'CNY',
|
||||
paidAt: null,
|
||||
rawPayload: {},
|
||||
items: [],
|
||||
}, {
|
||||
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
|
||||
})
|
||||
|
||||
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
|
||||
const bindings = getLegacyOrderFulfillmentBindingConfigs()
|
||||
return buildFulfillmentLookupResult({
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
detail,
|
||||
detailResult,
|
||||
bindings,
|
||||
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAdminFulfillmentBindingConfigs(
|
||||
payload: AdminFulfillmentBindingConfigSaveInput = {},
|
||||
) {
|
||||
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
|
||||
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, {
|
||||
getFulfillmentProfileByKey,
|
||||
})
|
||||
assertLegacyBindingConfigsOnly(normalizedBindings)
|
||||
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
|
||||
await syncConfiguredFulfillmentBindings()
|
||||
|
||||
return {
|
||||
filePath: getOrderFulfillmentBindingsFilePath(),
|
||||
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) {
|
||||
if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved',
|
||||
})
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildFulfillmentLookupResult,
|
||||
normalizeFulfillmentLookupPayload,
|
||||
resolveFulfillmentLookupDetail,
|
||||
} from './fulfillment.js'
|
||||
|
||||
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
|
||||
assert.deepEqual(
|
||||
normalizeFulfillmentLookupPayload({
|
||||
shopId: ' shop-1 ',
|
||||
platformOrderId: ' order-1 ',
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
},
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
|
||||
/请先填写店铺 ID/,
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveFulfillmentLookupDetail(
|
||||
{
|
||||
reason: 'missing_config',
|
||||
parsed: { items: [] },
|
||||
},
|
||||
'order-1',
|
||||
),
|
||||
/当前店铺缺少订单详情查询配置/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
|
||||
const result = buildFulfillmentLookupResult({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
platformOrderId: 'order-1',
|
||||
detail: {
|
||||
shopName: '店铺A',
|
||||
buyerName: '买家A',
|
||||
totalAmount: 12345,
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
skuName: '礼包A',
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
detailResult: {
|
||||
enriched: true,
|
||||
reason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
fallbackShopName: '备用店铺',
|
||||
})
|
||||
|
||||
assert.deepEqual(result, {
|
||||
order: {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
platformOrderId: 'order-1',
|
||||
buyerName: '买家A',
|
||||
totalAmountFen: 12345,
|
||||
totalAmount: '123.45',
|
||||
paidAt: '2026-05-04T10:00:00.000Z',
|
||||
enriched: true,
|
||||
enrichReason: 'ok',
|
||||
errorMessage: '',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
lineId: 'order-1:1:sku-1:item-1',
|
||||
itemTitle: '礼包A',
|
||||
quantity: 2,
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺A',
|
||||
externalItemId: 'item-1',
|
||||
externalSkuCode: 'sku-1',
|
||||
externalSkuName: '礼包A',
|
||||
latestSeenAt: null,
|
||||
orderItemCount: 2,
|
||||
configured: true,
|
||||
matchedBinding: {
|
||||
skuCode: 'internal-1',
|
||||
skuName: '内部商品',
|
||||
profileKey: 'profile-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { formatFenToAmount } from '../../../utils/money.js'
|
||||
import { isPlainObject, pickFirstNonEmpty } from './context.js'
|
||||
import { mapAdminObservedProductItem } from './domain.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) {
|
||||
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
|
||||
const shopId = String(payload.shopId || '').trim()
|
||||
const platformOrderId = String(payload.platformOrderId || '').trim()
|
||||
|
||||
if (!shopId) {
|
||||
throw createHttpError('请先填写店铺 ID', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请先填写平台订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
|
||||
})
|
||||
}
|
||||
|
||||
if (provider !== 'agiso' || platform !== 'xianyu') {
|
||||
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
return { provider, platform, shopId, platformOrderId }
|
||||
}
|
||||
|
||||
export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) {
|
||||
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
if (items.length > 0) {
|
||||
return { detail, items }
|
||||
}
|
||||
|
||||
const detailReason = String(detailResult?.reason || '').trim()
|
||||
const detailMessage = String(detailResult?.errorMessage || '').trim()
|
||||
let message = detailMessage
|
||||
|
||||
if (!message && detailReason === 'missing_config') {
|
||||
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
message = `未查询到订单 ${platformOrderId} 的商品明细`
|
||||
}
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFulfillmentLookupResult({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
detail = {},
|
||||
detailResult = {},
|
||||
bindings = [],
|
||||
fallbackShopName = '',
|
||||
}: {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
detail?: JsonObject
|
||||
detailResult?: JsonObject
|
||||
bindings?: JsonObject[]
|
||||
fallbackShopName?: string
|
||||
} = {}) {
|
||||
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
|
||||
const items = Array.isArray(detail.items) ? detail.items : []
|
||||
|
||||
return {
|
||||
order: {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
platformOrderId,
|
||||
buyerName: String(detail.buyerName || '').trim(),
|
||||
totalAmountFen: Number(detail.totalAmount || 0),
|
||||
totalAmount: formatFenToAmount(detail.totalAmount),
|
||||
paidAt: detail.paidAt || null,
|
||||
enriched: Boolean(detailResult?.enriched),
|
||||
enrichReason: String(detailResult?.reason || '').trim(),
|
||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
||||
},
|
||||
items: items.map((item, index) => {
|
||||
const observed = {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName: resolvedShopName,
|
||||
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
|
||||
externalSkuCode: pickFirstNonEmpty([
|
||||
item?.externalSkuCode,
|
||||
item?.skuCode,
|
||||
item?.externalItemId,
|
||||
item?.itemId,
|
||||
]),
|
||||
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
|
||||
latestSeenAt: null,
|
||||
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
|
||||
}
|
||||
|
||||
return {
|
||||
lineId: [
|
||||
platformOrderId,
|
||||
index + 1,
|
||||
observed.externalSkuCode || 'na',
|
||||
observed.externalItemId || 'na',
|
||||
].join(':'),
|
||||
itemTitle: pickFirstNonEmpty([
|
||||
item?.skuName,
|
||||
item?.externalSkuName,
|
||||
item?.externalSkuCode,
|
||||
item?.externalItemId,
|
||||
]),
|
||||
quantity: observed.orderItemCount,
|
||||
...mapAdminObservedProductItem(observed, bindings),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export {
|
||||
getAdminAgisoShopConfigs,
|
||||
updateAdminAgisoShopConfigs,
|
||||
} from "./agiso-service.js";
|
||||
|
||||
export {
|
||||
getAdminNinetyoneOrders,
|
||||
retryAdminNinetyoneOrder,
|
||||
failAdminNinetyoneOrder,
|
||||
} from "./ninetyone-service.js";
|
||||
|
||||
export {
|
||||
getAdminKuaishouEticketSourceConfig,
|
||||
updateAdminKuaishouEticketSourceConfig,
|
||||
queryAdminKuaishouEticketDetail,
|
||||
queryAdminKuaishouEticketShopInfo,
|
||||
consumeAdminKuaishouEticket,
|
||||
} from "./kuaishou-eticket-service.js";
|
||||
|
||||
export {
|
||||
listAdminCloudtentaclesSources,
|
||||
deleteAdminCloudtentaclesSource,
|
||||
updateAdminCloudtentaclesSourceConfig,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
validateAdminCloudtentaclesSession,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesCategories,
|
||||
getAdminCloudtentaclesSkuList,
|
||||
buyAdminCloudtentaclesSku,
|
||||
useAdminCloudtentaclesSku,
|
||||
getAdminCloudtentaclesKnapsack,
|
||||
listAdminCloudtentaclesVirtualNumbers,
|
||||
appointAdminCloudtentaclesVirtualNumber,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
verifyAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
backAdminCloudtentaclesVirtualNumber,
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
} from "./cloudtentacles-service.js";
|
||||
|
||||
export {
|
||||
getAdminFulfillmentBindingConfigs,
|
||||
lookupAdminFulfillmentBindingOrder,
|
||||
updateAdminFulfillmentBindingConfigs,
|
||||
} from "./fulfillment-bindings-service.js";
|
||||
|
||||
export {
|
||||
getAdminKuaishouCloudFulfillmentConfig,
|
||||
updateAdminKuaishouCloudFulfillmentConfig,
|
||||
} from "./kuaishou-cloud-fulfillment-service.js";
|
||||
|
||||
export {
|
||||
getAdminNotificationConfig,
|
||||
updateAdminNotificationConfig,
|
||||
testAdminNotification,
|
||||
getAdminScheduledJobsConfig,
|
||||
updateAdminScheduledJobsConfig,
|
||||
runAdminScheduledJobNow,
|
||||
} from "./notification-service.js";
|
||||
@@ -1,175 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
assertAdminFulfillmentBindingsInput,
|
||||
buildAdminFulfillmentBindingUniqueKey,
|
||||
normalizeAdminFulfillmentBindingItem,
|
||||
validateAdminFulfillmentBindingConfigs,
|
||||
} from './validation.js'
|
||||
|
||||
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
|
||||
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
|
||||
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => {
|
||||
const result = normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
shopName: ' 店铺 ',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ index: 0 },
|
||||
)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: 'manual_review',
|
||||
enabled: true,
|
||||
priority: undefined,
|
||||
config: {
|
||||
kuaishouShop: {
|
||||
note: 'keep',
|
||||
},
|
||||
},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
{ index: 1 },
|
||||
),
|
||||
/第 2 条规则缺少内部履约 SKU/,
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeAdminFulfillmentBindingItem(
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
skuCode: 'sku-1',
|
||||
},
|
||||
{ index: 2 },
|
||||
),
|
||||
/第 3 条规则至少需要一种外部匹配条件/,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
|
||||
assert.equal(
|
||||
buildAdminFulfillmentBindingUniqueKey({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-a',
|
||||
skuCode: 'sku-1',
|
||||
match: {
|
||||
externalItemId: '',
|
||||
externalSkuCode: '',
|
||||
externalSkuName: ' 礼包 A ',
|
||||
},
|
||||
}),
|
||||
'91kaquan::kuaishou::shop-a::::::礼包 a::sku-1',
|
||||
)
|
||||
})
|
||||
|
||||
test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateAdminFulfillmentBindingConfigs(
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'missing-profile',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
kuaishouEticketSource: {},
|
||||
resolveKuaishouEticketShopConfig() {
|
||||
return null
|
||||
},
|
||||
async getFulfillmentProfileByKey() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
),
|
||||
/第 1 条规则使用了不存在的履约方式: missing-profile/,
|
||||
)
|
||||
})
|
||||
|
||||
test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateAdminFulfillmentBindingConfigs(
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'manual_review',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: 'sku-1',
|
||||
profileKey: 'manual_review',
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
kuaishouEticketSource: {},
|
||||
resolveKuaishouEticketShopConfig() {
|
||||
return null
|
||||
},
|
||||
async getFulfillmentProfileByKey() {
|
||||
return { key: 'manual_review' }
|
||||
},
|
||||
},
|
||||
),
|
||||
/第 2 条规则与其它规则重复/,
|
||||
)
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { normalizeProductName } from '../../order/product-match-service.js'
|
||||
import { isPlainObject } from './context.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function assertAdminFulfillmentBindingsInput(bindings: unknown) {
|
||||
if (Array.isArray(bindings)) {
|
||||
return
|
||||
}
|
||||
|
||||
throw createHttpError('履约配置格式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_payload',
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeAdminFulfillmentBindingItem(
|
||||
rawBinding: unknown,
|
||||
options: JsonObject = {},
|
||||
) {
|
||||
const index = Number(options.index || 0)
|
||||
if (!isPlainObject(rawBinding)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则格式不正确`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_item',
|
||||
})
|
||||
}
|
||||
|
||||
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawBinding.platform || '').trim()
|
||||
const shopId = String(rawBinding.shopId || '').trim()
|
||||
const shopName = String(rawBinding.shopName || '').trim()
|
||||
const skuCode = String(rawBinding.skuCode || '').trim()
|
||||
const skuName = String(rawBinding.skuName || '').trim()
|
||||
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
|
||||
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
|
||||
|
||||
if (!skuCode) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
|
||||
})
|
||||
}
|
||||
|
||||
if (!externalItemId && !externalSkuCode && !externalSkuName) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
enabled: rawBinding.enabled !== false,
|
||||
priority: rawBinding.priority,
|
||||
config,
|
||||
match: {
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
externalSkuName,
|
||||
config: isPlainObject(match.config) ? match.config : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAdminFulfillmentBindingUniqueKey(binding) {
|
||||
return [
|
||||
String(binding?.provider || '').trim(),
|
||||
String(binding?.platform || '').trim(),
|
||||
String(binding?.shopId || '').trim(),
|
||||
String(binding?.match?.externalItemId || '').trim(),
|
||||
String(binding?.match?.externalSkuCode || '').trim(),
|
||||
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
|
||||
String(binding?.skuCode || '').trim(),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
export async function validateAdminFulfillmentBindingConfigs(
|
||||
bindings: JsonObject[],
|
||||
options: JsonObject = {},
|
||||
) {
|
||||
assertAdminFulfillmentBindingsInput(bindings)
|
||||
|
||||
const getFulfillmentProfileByKey = (
|
||||
options.getFulfillmentProfileByKey || (async () => null)
|
||||
) as (profileKey: string) => Promise<unknown>
|
||||
const seenKeys = new Set()
|
||||
const normalizedBindings: JsonObject[] = []
|
||||
|
||||
for (const [index, rawBinding] of bindings.entries()) {
|
||||
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index })
|
||||
|
||||
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
|
||||
if (!profile) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
|
||||
})
|
||||
}
|
||||
|
||||
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
|
||||
if (seenKeys.has(uniqueKey)) {
|
||||
throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
|
||||
})
|
||||
}
|
||||
|
||||
seenKeys.add(uniqueKey)
|
||||
normalizedBindings.push(normalized)
|
||||
}
|
||||
|
||||
return normalizedBindings
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildAdminAgisoMessagingSavePayload,
|
||||
buildAdminAgisoShopConfigUpdate,
|
||||
normalizeAdminCloudtentaclesSourceConfigPayload,
|
||||
} from './writes.js'
|
||||
|
||||
test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => {
|
||||
assert.deepEqual(
|
||||
buildAdminAgisoShopConfigUpdate(
|
||||
{
|
||||
shopId: ' shop-1 ',
|
||||
shopName: ' ',
|
||||
accessToken: ' new-token ',
|
||||
messageTemplate: ' ',
|
||||
autoDeliveryMessageTemplate: ' done ',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
shopName: '旧店铺',
|
||||
accessToken: 'old-token',
|
||||
messageTemplate: 'old-template',
|
||||
autoDeliveryMessageTemplate: 'old-auto',
|
||||
appSecret: 'keep-secret',
|
||||
},
|
||||
),
|
||||
{
|
||||
shopId: 'shop-1',
|
||||
config: {
|
||||
accessToken: 'new-token',
|
||||
autoDeliveryMessageTemplate: 'done',
|
||||
appSecret: 'keep-secret',
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => {
|
||||
assert.deepEqual(
|
||||
buildAdminAgisoMessagingSavePayload(
|
||||
{
|
||||
defaults: {
|
||||
messageTemplate: ' next\\nline ',
|
||||
autoDeliveryMessageTemplate: ' ',
|
||||
},
|
||||
shops: [
|
||||
{
|
||||
shopId: ' shop-1 ',
|
||||
shopName: '店铺一',
|
||||
accessToken: '',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
shopId: 'shop-2',
|
||||
shopName: ' 店铺二 ',
|
||||
accessToken: ' token-2 ',
|
||||
messageTemplate: ' hi ',
|
||||
apiVersion: ' v2 ',
|
||||
},
|
||||
{
|
||||
shopId: '',
|
||||
accessToken: 'skip',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
currentDefaults: {
|
||||
messageTemplate: 'old',
|
||||
autoDeliveryMessageTemplate: 'old-auto',
|
||||
},
|
||||
currentMap: {
|
||||
'shop-1': {
|
||||
accessToken: 'token-1',
|
||||
autoDeliveryMessageTemplate: 'keep-auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
defaults: {
|
||||
messageTemplate: 'next\nline',
|
||||
},
|
||||
shops: {
|
||||
'shop-1': {
|
||||
accessToken: 'token-1',
|
||||
autoDeliveryMessageTemplate: 'keep-auto',
|
||||
enabled: true,
|
||||
shopName: '店铺一',
|
||||
},
|
||||
'shop-2': {
|
||||
accessToken: 'token-2',
|
||||
apiVersion: 'v2',
|
||||
messageTemplate: 'hi',
|
||||
shopName: '店铺二',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => {
|
||||
assert.deepEqual(
|
||||
normalizeAdminCloudtentaclesSourceConfigPayload({
|
||||
enabled: false,
|
||||
baseUrl: ' ',
|
||||
username: ' user ',
|
||||
password: ' pass ',
|
||||
phone: ' 13800138000 ',
|
||||
deviceId: ' ',
|
||||
deviceType: '2',
|
||||
}),
|
||||
{
|
||||
key: 'default',
|
||||
label: '',
|
||||
enabled: false,
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
phone: '13800138000',
|
||||
deviceId: '-',
|
||||
deviceType: 2,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { applyOptionalStringField } from "./domain.js";
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) {
|
||||
const shopId = String(item?.shopId || "").trim();
|
||||
if (!shopId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next: JsonObject = { ...current };
|
||||
const shopName = String(item?.shopName || "").trim();
|
||||
const accessToken = String(item?.accessToken || "").trim();
|
||||
const messageTemplate = String(item?.messageTemplate || "").trim();
|
||||
const autoDeliveryMessageTemplate = String(
|
||||
item?.autoDeliveryMessageTemplate || ""
|
||||
).trim();
|
||||
const appSecret = String(item?.appSecret || "").trim();
|
||||
const apiVersion = String(item?.apiVersion || "").trim();
|
||||
const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim();
|
||||
|
||||
if (shopName) {
|
||||
next.shopName = shopName;
|
||||
} else {
|
||||
delete next.shopName;
|
||||
}
|
||||
if (accessToken) {
|
||||
next.accessToken = accessToken;
|
||||
}
|
||||
if (messageTemplate) {
|
||||
next.messageTemplate = messageTemplate;
|
||||
} else if (typeof item?.messageTemplate === "string") {
|
||||
delete next.messageTemplate;
|
||||
}
|
||||
if (autoDeliveryMessageTemplate) {
|
||||
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate;
|
||||
} else if (typeof item?.autoDeliveryMessageTemplate === "string") {
|
||||
delete next.autoDeliveryMessageTemplate;
|
||||
}
|
||||
if (appSecret) {
|
||||
next.appSecret = appSecret;
|
||||
}
|
||||
if (apiVersion) {
|
||||
next.apiVersion = apiVersion;
|
||||
}
|
||||
if (sendMessageEndpoint) {
|
||||
next.sendMessageEndpoint = sendMessageEndpoint;
|
||||
}
|
||||
if (typeof item?.enabled === "boolean") {
|
||||
next.enabled = item.enabled;
|
||||
}
|
||||
|
||||
if (!next.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { shopId, config: next };
|
||||
}
|
||||
|
||||
export function buildAdminAgisoMessagingSavePayload(
|
||||
payload: JsonObject = {},
|
||||
options: JsonObject = {}
|
||||
) {
|
||||
const rawItems = Array.isArray(payload.shops) ? payload.shops : [];
|
||||
const currentDefaults: JsonObject = options.currentDefaults || {};
|
||||
const currentMap: Record<string, JsonObject> = options.currentMap || {};
|
||||
const nextDefaults: JsonObject = {
|
||||
...currentDefaults,
|
||||
};
|
||||
const nextMap: Record<string, JsonObject> = {};
|
||||
|
||||
applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults);
|
||||
applyOptionalStringField(
|
||||
nextDefaults,
|
||||
"autoDeliveryMessageTemplate",
|
||||
payload.defaults
|
||||
);
|
||||
|
||||
for (const item of rawItems) {
|
||||
const shopId = String(item?.shopId || "").trim();
|
||||
if (!shopId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = buildAdminAgisoShopConfigUpdate(
|
||||
item,
|
||||
currentMap[shopId] || {}
|
||||
);
|
||||
if (!updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextMap[updated.shopId] = updated.config;
|
||||
}
|
||||
|
||||
return {
|
||||
defaults: nextDefaults,
|
||||
shops: nextMap,
|
||||
};
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import {
|
||||
markInventoryItemDelivered,
|
||||
releaseReservedInventoryItem,
|
||||
} from '../../../repositories/inventory-repo.js'
|
||||
import { updateClaimToken } from '../../../repositories/claim-token-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
getTaskInventoryBindingById,
|
||||
listTaskInventoryBindingsByTaskId,
|
||||
} from '../../../repositories/task-inventory-binding-repo.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js'
|
||||
import { reserveInventoryForTask } from '../../order/inventory-service.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { closeTencentBrowserSession } from '../../session/session.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import {
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
parseTaskContext,
|
||||
} from '../admin-read-shared-helpers.js'
|
||||
import {
|
||||
getRequiredTask,
|
||||
mapTaskActionPayload,
|
||||
} from '../admin-task-read-helpers.js'
|
||||
import {
|
||||
ensureViewerCanOperateAssistedTask,
|
||||
getTaskClaimExpiresAt,
|
||||
isRecoverableTaskSessionCloseError,
|
||||
maskCode,
|
||||
normalizeManualDispatchOutcome,
|
||||
resolveTaskInventoryGroupCodes,
|
||||
} from './shared.js'
|
||||
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminViewerSessionInput,
|
||||
} from '../../../types/admin-read-inputs.js'
|
||||
import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js'
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskBindingReleaseResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from '../../../types/admin-write-models.js'
|
||||
import type { TaskRow } from '../../../types/repository-rows.js'
|
||||
import type { TaskUpdatePatch } from '../../../types/repository-inputs.js'
|
||||
|
||||
type CloseAdminTaskDeps = {
|
||||
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
|
||||
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
|
||||
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
|
||||
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
|
||||
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
|
||||
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
|
||||
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
|
||||
nowIso?: () => string
|
||||
}
|
||||
|
||||
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
|
||||
if (!primaryInventoryItemId) {
|
||||
throw createHttpError('当前任务没有预占库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_no_reserved_inventory',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存项', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(primaryInventoryItemId, now)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'waiting_inventory',
|
||||
inventory_status: 'pending',
|
||||
last_error: '已手动释放预占库存项',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseAdminTaskInventoryBinding(
|
||||
taskId: AdminEntityIdInput,
|
||||
bindingId: AdminEntityIdInput,
|
||||
): Promise<AdminTaskBindingReleaseResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const binding = await getTaskInventoryBindingById(Number(bindingId))
|
||||
|
||||
if (!binding || Number(binding.task_id) !== Number(task.id)) {
|
||||
throw createHttpError('任务库存绑定不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_inventory_binding_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(binding.binding_status || '').trim() !== 'reserved') {
|
||||
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_inventory_binding_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能释放库存绑定', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_release_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await releaseReservedInventoryItem(binding.inventory_item_id, now)
|
||||
|
||||
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
|
||||
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
|
||||
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
|
||||
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
|
||||
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
|
||||
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
|
||||
? 'waiting_inventory'
|
||||
: task.task_status
|
||||
|
||||
if (!hasReservedBindings) {
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'inventory_binding_released', {
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
roleKey: String(binding.role_key || '').trim(),
|
||||
}, now)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
bindingId: Number(binding.id),
|
||||
inventoryItemId: Number(binding.inventory_item_id),
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAdminTaskClaimLink(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重新生成领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_regenerate_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
|
||||
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
|
||||
statusCode: 403,
|
||||
errorCode: 'admin_task_regenerate_permission_denied',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
if (primaryClaimTokenId) {
|
||||
await updateClaimToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
task_status: 'link_generated',
|
||||
last_error: '',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
claimUrl: claimToken.claimUrl,
|
||||
token: claimToken.token,
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmAdminTaskAssistedRole(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
|
||||
await confirmClaimRoleForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function redeemAdminTaskAssisted(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
|
||||
await redeemClaimTaskForAdminTask(task.id)
|
||||
const updatedTask = await getRequiredTask(task.id)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
return closeAdminTaskWithDeps(taskId)
|
||||
}
|
||||
|
||||
export async function closeAdminTaskWithDeps(
|
||||
taskId: AdminEntityIdInput,
|
||||
{
|
||||
getRequiredTask: getTask = getRequiredTask,
|
||||
listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId,
|
||||
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
|
||||
updateClaimToken: updateToken = updateClaimToken,
|
||||
updateTask: updateTaskRecord = updateTask,
|
||||
createTaskEvent: createEvent = createTaskEvent,
|
||||
closeTencentBrowserSession: closeSession = closeTencentBrowserSession,
|
||||
nowIso: getNowIso = nowIso,
|
||||
}: CloseAdminTaskDeps = {},
|
||||
): Promise<AdminTaskActionResponse> {
|
||||
const task = await getTask(taskId)
|
||||
|
||||
if (task.task_status === 'redeemed') {
|
||||
throw createHttpError('已兑换任务不能关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_close_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = getNowIso()
|
||||
const bindings = await listBindings(task.id)
|
||||
const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved')
|
||||
const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0)))
|
||||
const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed')
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
let browserSessionClosed = false
|
||||
|
||||
if (primaryClaimTokenId) {
|
||||
await updateToken(primaryClaimTokenId, {
|
||||
status: 'revoked',
|
||||
expired_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
try {
|
||||
await closeSession(task.browser_session_id)
|
||||
browserSessionClosed = true
|
||||
} catch (error) {
|
||||
if (!isRecoverableTaskSessionCloseError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const inventoryItemId of releasedInventoryItemIds) {
|
||||
await releaseReserved(inventoryItemId, now)
|
||||
}
|
||||
|
||||
const closeReasonParts = ['已手动关闭任务']
|
||||
if (primaryClaimTokenId) {
|
||||
closeReasonParts.push('领取链接已失效')
|
||||
}
|
||||
if (releasedInventoryItemIds.length > 0) {
|
||||
closeReasonParts.push('预占库存已释放')
|
||||
}
|
||||
|
||||
const updatedTask = await updateTaskRecord(task.id, {
|
||||
task_status: 'closed',
|
||||
inventory_status: hasConsumedBindings ? 'consumed' : 'pending',
|
||||
delivery_status: 'closed',
|
||||
user_action_status: 'closed',
|
||||
claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at,
|
||||
browser_session_id: '',
|
||||
last_error: task.last_error || closeReasonParts.join(','),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createEvent(task.id, 'task_closed', {
|
||||
claimTokenRevoked: Boolean(primaryClaimTokenId),
|
||||
releasedInventoryItemIds,
|
||||
releasedInventoryCount: releasedInventoryItemIds.length,
|
||||
browserSessionClosed,
|
||||
}, now)
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'manual_review',
|
||||
delivery_status: task.delivery_status || 'pending',
|
||||
last_error: task.last_error || '已转人工处理',
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
|
||||
return {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
const now = nowIso()
|
||||
|
||||
if (isManualDispatchTask(task)) {
|
||||
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务状态不允许重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_retry_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const primaryRequirement = taskContext.primaryRequirement || null
|
||||
const orderItems = await listOrderItemsByOrderId(task.order_id)
|
||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
let claimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
let nextStatus = 'link_generated'
|
||||
let lastError = ''
|
||||
let claimExpiresAt = getTaskClaimExpiresAt(task)
|
||||
let claimUrl = ''
|
||||
let token = ''
|
||||
|
||||
if (!reservedInventoryItemId) {
|
||||
const reserved = await reserveInventoryForTask({
|
||||
skuCode: orderItem?.sku_code || '',
|
||||
taskId: task.id,
|
||||
credentialType: primaryRequirement?.credentialType || 'tencent_code',
|
||||
roleKey: primaryRequirement?.roleKey || 'primary_code',
|
||||
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
|
||||
})
|
||||
|
||||
if (!reserved) {
|
||||
nextStatus = 'waiting_inventory'
|
||||
lastError = '库存不足,等待可用库存凭据'
|
||||
} else {
|
||||
reservedInventoryItemId = reserved.id
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStatus === 'link_generated' && !claimTokenId) {
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
claimTokenId = claimToken.id
|
||||
claimExpiresAt = claimToken.expired_at
|
||||
claimUrl = claimToken.claimUrl
|
||||
token = claimToken.token
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextStatus,
|
||||
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
|
||||
claim_token: token || task.claim_token || '',
|
||||
claim_expires_at: claimExpiresAt,
|
||||
last_error: lastError,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
const response: AdminTaskActionResponse = {
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
}
|
||||
|
||||
if (claimUrl) {
|
||||
response.claimUrl = claimUrl
|
||||
response.token = token
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function completeAdminTaskManualDispatch(
|
||||
taskId: AdminEntityIdInput,
|
||||
payload: AdminTaskManualDispatchInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminTaskManualDispatchResponse> {
|
||||
const task = await getRequiredTask(taskId)
|
||||
|
||||
if (!isManualDispatchTask(task)) {
|
||||
throw createHttpError('当前任务不是人工履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_not_manual_dispatch',
|
||||
})
|
||||
}
|
||||
|
||||
if (['redeemed', 'closed'].includes(task.task_status)) {
|
||||
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_task_manual_dispatch_already_completed',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const outcome = normalizeManualDispatchOutcome(payload.outcome)
|
||||
const resultMessage = String(payload.resultMessage || '').trim()
|
||||
const deliveryReference = String(payload.deliveryReference || '').trim()
|
||||
const deliveredCredential = String(payload.deliveredCredential || '').trim()
|
||||
const context = parseTaskContext(task)
|
||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
|
||||
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
|
||||
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
|
||||
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
|
||||
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
|
||||
? 'consumed'
|
||||
: task.inventory_status || 'not_required'
|
||||
const manualDispatch = {
|
||||
outcome,
|
||||
deliveryReference,
|
||||
deliveredCredential,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
completedAt: now,
|
||||
completedBy: session
|
||||
? {
|
||||
userId: Number(session.userId || 0),
|
||||
username: String(session.username || ''),
|
||||
role: String(session.role || ''),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
if (outcome === 'delivered' && inventoryItemId) {
|
||||
await markInventoryItemDelivered(inventoryItemId, now)
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
inventory_status: nextInventoryStatus,
|
||||
delivery_status: nextDeliveryStatus,
|
||||
result_code: resultCode,
|
||||
result_message: resultMessage || fallbackMessage,
|
||||
user_action_status: 'not_required',
|
||||
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
|
||||
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
|
||||
context_json: JSON.stringify({
|
||||
...context,
|
||||
manualDispatch,
|
||||
}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(task.id, 'manual_dispatch_completed', {
|
||||
outcome,
|
||||
resultCode,
|
||||
resultMessage: resultMessage || fallbackMessage,
|
||||
deliveryReference,
|
||||
deliveredCredentialMasked: maskCode(deliveredCredential),
|
||||
completedBy: manualDispatch.completedBy,
|
||||
}, now)
|
||||
|
||||
const taskAfterAutoDelivery = outcome === 'delivered'
|
||||
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: await getOrderById(task.order_id),
|
||||
task: updatedTask,
|
||||
trigger: 'manual_dispatch_completed',
|
||||
})).task || updatedTask
|
||||
: updatedTask
|
||||
|
||||
return {
|
||||
outcome,
|
||||
task: mapTaskActionPayload(taskAfterAutoDelivery),
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js'
|
||||
import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
|
||||
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
|
||||
import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js'
|
||||
|
||||
export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise<AdminWebhookReplayResponse> {
|
||||
const event = await getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
|
||||
throw createHttpError('当前只支持重放 agiso webhook', {
|
||||
statusCode: 409,
|
||||
errorCode: 'admin_webhook_replay_not_supported',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await replayAgisoTradeWebhookEvent(event)
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
replayed: true,
|
||||
result,
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js'
|
||||
|
||||
test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => {
|
||||
assert.deepEqual(
|
||||
resolveBindingMatchShopIds({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: '91kaquan',
|
||||
}),
|
||||
['91kaquan'],
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => {
|
||||
assert.deepEqual(
|
||||
resolveBindingMatchShopIds({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: '',
|
||||
}),
|
||||
[''],
|
||||
)
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
|
||||
import {
|
||||
getKuaishouCloudFulfillmentConfig,
|
||||
mapKuaishouCloudFulfillmentItemsToBindings,
|
||||
@@ -108,10 +107,7 @@ async function ensureCoreProfiles() {
|
||||
|
||||
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
|
||||
const timestamp = nowIso()
|
||||
const bindingsToApply = [
|
||||
...getLegacyOrderFulfillmentBindingConfigs(),
|
||||
...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()),
|
||||
]
|
||||
const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
|
||||
|
||||
await query('DELETE FROM product_match_rules')
|
||||
await query('DELETE FROM sku_fulfillment_bindings')
|
||||
@@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
|
||||
|
||||
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
||||
const matchShopIds = resolveBindingMatchShopIds(binding)
|
||||
const match = binding.match || {}
|
||||
const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').trim()
|
||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||
@@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBindingMatchShopIds(binding: JsonObject = {}) {
|
||||
function resolveBindingMatchShopIds(binding: JsonObject = {}) {
|
||||
return [String(binding.shopId || '').trim()]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js'
|
||||
|
||||
test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => {
|
||||
const events = []
|
||||
const invalidated = []
|
||||
const reserveCalls = []
|
||||
const reloadCalls = []
|
||||
|
||||
const context = {
|
||||
task: {
|
||||
id: 42,
|
||||
browser_session_id: 'txbs-test',
|
||||
},
|
||||
orderItem: {
|
||||
sku_code: 'df-cdk',
|
||||
},
|
||||
}
|
||||
const initialInventoryItem = {
|
||||
id: 1,
|
||||
display_value: 'DJQFf7HgONCL4XD4EH',
|
||||
credential_type: 'tencent_code',
|
||||
inventory_group_code: 'A组',
|
||||
}
|
||||
const replacementInventoryItem = {
|
||||
id: 2,
|
||||
display_value: 'ABCD1234EFGH5678',
|
||||
credential_type: 'tencent_code',
|
||||
inventory_group_code: 'A组',
|
||||
}
|
||||
|
||||
let redeemCount = 0
|
||||
const result = await redeemClaimTaskWithInventoryFallbackWithDeps(
|
||||
context,
|
||||
initialInventoryItem,
|
||||
{
|
||||
reloadTencentBrowserSession: async (sessionId) => {
|
||||
reloadCalls.push(sessionId)
|
||||
return { sessionId, status: 'ready_to_redeem' }
|
||||
},
|
||||
redeemTencentBrowserSession: async (sessionId, payload) => {
|
||||
redeemCount += 1
|
||||
if (redeemCount === 1) {
|
||||
assert.equal(sessionId, 'txbs-test')
|
||||
assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH')
|
||||
return {
|
||||
redeem: {
|
||||
final: {
|
||||
redeem: {
|
||||
iRet: -183,
|
||||
sMsg: '该CDKEY不存在,请您确认后输入!',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(payload.code, 'ABCD1234EFGH5678')
|
||||
return {
|
||||
redeem: {
|
||||
final: {
|
||||
redeem: {
|
||||
iRet: 0,
|
||||
sMsg: '领取成功',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => {
|
||||
invalidated.push({ inventoryItemId, reason, updatedAt })
|
||||
return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt }
|
||||
},
|
||||
reserveInventoryForTask: async (payload) => {
|
||||
reserveCalls.push(payload)
|
||||
return replacementInventoryItem
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
events.push({ taskId, eventType, payload, createdAt })
|
||||
},
|
||||
nowIso: (() => {
|
||||
const values = [
|
||||
'2026-04-14T12:03:34.000Z',
|
||||
'2026-04-14T12:03:35.000Z',
|
||||
]
|
||||
let index = 0
|
||||
return () => values[index++] || values[values.length - 1]
|
||||
})(),
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(result.inventoryItem.id, 2)
|
||||
assert.equal(result.classification.outcome, 'success')
|
||||
assert.equal(result.attempts.length, 2)
|
||||
assert.deepEqual(
|
||||
result.attempts.map((attempt) => ({
|
||||
attempt: attempt.attempt,
|
||||
inventoryItemId: attempt.inventoryItemId,
|
||||
outcome: attempt.outcome,
|
||||
resultCode: attempt.resultCode,
|
||||
})),
|
||||
[
|
||||
{
|
||||
attempt: 1,
|
||||
inventoryItemId: 1,
|
||||
outcome: 'code_invalid',
|
||||
resultCode: '-183',
|
||||
},
|
||||
{
|
||||
attempt: 2,
|
||||
inventoryItemId: 2,
|
||||
outcome: 'success',
|
||||
resultCode: '0',
|
||||
},
|
||||
],
|
||||
)
|
||||
assert.deepEqual(invalidated, [
|
||||
{
|
||||
inventoryItemId: 1,
|
||||
reason: '该CDKEY不存在,请您确认后输入!',
|
||||
updatedAt: '2026-04-14T12:03:34.000Z',
|
||||
},
|
||||
])
|
||||
assert.deepEqual(reserveCalls, [
|
||||
{
|
||||
skuCode: 'df-cdk',
|
||||
taskId: 42,
|
||||
credentialType: 'tencent_code',
|
||||
roleKey: 'primary_code',
|
||||
inventoryGroupCodes: ['A组'],
|
||||
},
|
||||
])
|
||||
assert.deepEqual(reloadCalls, ['txbs-test'])
|
||||
assert.deepEqual(
|
||||
events.map((event) => ({
|
||||
eventType: event.eventType,
|
||||
taskId: event.taskId,
|
||||
createdAt: event.createdAt,
|
||||
payload: event.payload,
|
||||
})),
|
||||
[
|
||||
{
|
||||
eventType: 'claim_redeem_code_invalid',
|
||||
taskId: 42,
|
||||
createdAt: '2026-04-14T12:03:34.000Z',
|
||||
payload: {
|
||||
inventoryItemId: 1,
|
||||
codeMasked: 'DJQF****D4EH',
|
||||
resultCode: '-183',
|
||||
resultMessage: '该CDKEY不存在,请您确认后输入!',
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: 'claim_redeem_inventory_replaced',
|
||||
taskId: 42,
|
||||
createdAt: '2026-04-14T12:03:35.000Z',
|
||||
payload: {
|
||||
previousInventoryItemId: 1,
|
||||
previousCodeMasked: 'DJQF****D4EH',
|
||||
previousOutcome: 'code_invalid',
|
||||
nextInventoryItemId: 2,
|
||||
nextCodeMasked: 'ABCD****5678',
|
||||
credentialType: 'tencent_code',
|
||||
inventoryGroupCode: 'A组',
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => {
|
||||
const events = []
|
||||
const consumed = []
|
||||
const reserveCalls = []
|
||||
|
||||
const context = {
|
||||
task: {
|
||||
id: 57,
|
||||
browser_session_id: 'txbs-used',
|
||||
},
|
||||
orderItem: {
|
||||
sku_code: 'df-cdk-used',
|
||||
},
|
||||
}
|
||||
const initialInventoryItem = {
|
||||
id: 7,
|
||||
display_value: 'USED1234CODE5678',
|
||||
credential_type: 'tencent_code',
|
||||
inventory_group_code: 'B组',
|
||||
}
|
||||
|
||||
let thrown = null
|
||||
try {
|
||||
await redeemClaimTaskWithInventoryFallbackWithDeps(
|
||||
context,
|
||||
initialInventoryItem,
|
||||
{
|
||||
redeemTencentBrowserSession: async () => ({
|
||||
redeem: {
|
||||
final: {
|
||||
redeem: {
|
||||
popup: {
|
||||
text: '兑换结果',
|
||||
detail: '兑换码已使用。',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => {
|
||||
consumed.push({ inventoryItemId, reason, updatedAt })
|
||||
return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt }
|
||||
},
|
||||
reserveInventoryForTask: async (payload) => {
|
||||
reserveCalls.push(payload)
|
||||
return null
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
events.push({ taskId, eventType, payload, createdAt })
|
||||
},
|
||||
nowIso: () => '2026-04-14T12:05:00.000Z',
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
assert.ok(thrown)
|
||||
assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted')
|
||||
assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory')
|
||||
assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending')
|
||||
assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending')
|
||||
assert.equal(thrown.redeemTaskState.attempts.length, 1)
|
||||
assert.deepEqual(consumed, [
|
||||
{
|
||||
inventoryItemId: 7,
|
||||
reason: '兑换码已使用。',
|
||||
updatedAt: '2026-04-14T12:05:00.000Z',
|
||||
},
|
||||
])
|
||||
assert.deepEqual(reserveCalls, [
|
||||
{
|
||||
skuCode: 'df-cdk-used',
|
||||
taskId: 57,
|
||||
credentialType: 'tencent_code',
|
||||
roleKey: 'primary_code',
|
||||
inventoryGroupCodes: ['B组'],
|
||||
},
|
||||
])
|
||||
assert.deepEqual(
|
||||
events.map((event) => ({
|
||||
taskId: event.taskId,
|
||||
eventType: event.eventType,
|
||||
payload: event.payload,
|
||||
createdAt: event.createdAt,
|
||||
})),
|
||||
[
|
||||
{
|
||||
taskId: 57,
|
||||
eventType: 'claim_redeem_code_used',
|
||||
payload: {
|
||||
inventoryItemId: 7,
|
||||
codeMasked: 'USED****5678',
|
||||
resultCode: '',
|
||||
resultMessage: '兑换码已使用。',
|
||||
},
|
||||
createdAt: '2026-04-14T12:05:00.000Z',
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
export {
|
||||
getClaimDetail,
|
||||
getClaimDetailForAdminTask,
|
||||
getClaimSessionSummary,
|
||||
getClaimSessionSummaryForAdminTask,
|
||||
confirmClaimRole,
|
||||
confirmClaimRoleForAdminTask,
|
||||
redeemClaimTask,
|
||||
redeemClaimTaskForAdminTask,
|
||||
getClaimScreenshotPath,
|
||||
} from './session/actions.js'
|
||||
|
||||
export {
|
||||
createClaimSession,
|
||||
createClaimSessionForAdminTask,
|
||||
reloadClaimSession,
|
||||
reloadClaimSessionForAdminTask,
|
||||
closeClaimSession,
|
||||
closeClaimSessionForAdminTask,
|
||||
} from './session/lifecycle.js'
|
||||
|
||||
export {
|
||||
getClaimContext,
|
||||
} from './session/context.js'
|
||||
|
||||
export {
|
||||
redeemClaimTaskWithInventoryFallbackWithDeps,
|
||||
} from './session/redeem.js'
|
||||
+110
-70
@@ -1,32 +1,95 @@
|
||||
import { buildClaimUrl } from '../claim-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { maskCode as maskCodeValue } from '../../../utils/masking.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js'
|
||||
import {
|
||||
parseTaskContext as parseTaskContextValue,
|
||||
parseTaskState as parseTaskStateValue,
|
||||
} from '../../../utils/task-json.js'
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
|
||||
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
|
||||
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
export const REDEEM_REPLACEMENT_LIMIT = 10
|
||||
export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
|
||||
|
||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) {
|
||||
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
|
||||
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
|
||||
const finalRedeem = session?.redeem?.final?.redeem || null
|
||||
export async function getClaimContext(token: unknown) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createHttpError('缺少领取 token', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_claim_token',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await findClaimTokenByToken(normalized)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接无效或不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const task = await findTaskByClaimTokenId(claimToken.id)
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.status !== 'active') {
|
||||
throw createHttpError('领取链接当前不可用', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_inactive',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
|
||||
const expiredContext = await expireClaimContext(claimToken, task)
|
||||
|
||||
throw createHttpError('领取链接已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_expired',
|
||||
context: expiredContext,
|
||||
})
|
||||
}
|
||||
|
||||
const [order, orderItem] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getOrderItemById(task.order_item_id),
|
||||
])
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) {
|
||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim',
|
||||
flowType: 'kuaishou_cloud',
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
executorKey: task.executor_key || '',
|
||||
requiresSupportReview: isAssistedClaimTask(task),
|
||||
requiresSupportReview: false,
|
||||
expiresAt: claimToken.expired_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
@@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
session,
|
||||
session: null,
|
||||
kuaishouCloudFulfillment,
|
||||
result: task.redeemed_at || session?.status === 'redeemed'
|
||||
result: task.redeemed_at
|
||||
? {
|
||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
||||
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
|
||||
screenshotReady,
|
||||
screenshotUrl,
|
||||
resultCode: String(task.result_code || ''),
|
||||
resultMessage: String(task.result_message || ''),
|
||||
screenshotReady: false,
|
||||
screenshotUrl: '',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
@@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTaskCanProceed(task) {
|
||||
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
|
||||
throw createHttpError('当前任务已经结束,不能继续操作', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_task_closed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPublicClaimActionAllowed(task, action) {
|
||||
if (!isAssistedClaimTask(task)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = action === 'confirm'
|
||||
? '当前商品需要客服复核角色,请登录后联系人工继续'
|
||||
: '当前商品需要客服确认后再执行兑换,请联系人工继续'
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_support_review_required',
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeTaskContext(task, patch = {}) {
|
||||
return {
|
||||
...parseTaskContext(task),
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTaskState(task) {
|
||||
return parseTaskStateValue(task)
|
||||
}
|
||||
|
||||
export function isAssistedClaimTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
export function parseTaskContext(task) {
|
||||
function parseTaskContext(task) {
|
||||
return parseTaskContextValue(task)
|
||||
}
|
||||
|
||||
export function normalizeClaimLoginType(loginType) {
|
||||
return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq'
|
||||
}
|
||||
async function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = await updateClaimToken(claimToken.id, {
|
||||
status: 'expired',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
export function isRecoverableSessionError(error) {
|
||||
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
||||
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
||||
}
|
||||
let nextTask = task
|
||||
|
||||
export function maskCode(value) {
|
||||
return maskCodeValue(value, { shortMask: '****' })
|
||||
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
||||
if (task.primary_inventory_item_id) {
|
||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
||||
}
|
||||
|
||||
nextTask = await updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
inventory_status: 'pending',
|
||||
user_action_status: 'expired',
|
||||
last_error: '领取链接已过期,预占库存项已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken: nextClaimToken,
|
||||
task: nextTask,
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
prepareKuaishouCloudFulfillmentTask,
|
||||
refreshKuaishouCloudTaskRoleInfo,
|
||||
} from '../fulfillment/kuaishou-cloud-task-service.js'
|
||||
import { buildClaimDetailPayload } from './session/shared.js'
|
||||
import { getClaimContext } from './session/context.js'
|
||||
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
||||
|
||||
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
||||
@@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
|
||||
task,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import {
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
} from '../../session/session.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
getInventoryItemById,
|
||||
markInventoryItemDelivered,
|
||||
} from '../../../repositories/inventory-repo.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js'
|
||||
import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { getClaimContext, getClaimContextByTaskId } from './context.js'
|
||||
import { loadTaskSession, syncTaskWithSession } from './runtime.js'
|
||||
import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js'
|
||||
import {
|
||||
assertPublicClaimActionAllowed,
|
||||
assertTaskCanProceed,
|
||||
buildClaimDetailPayload,
|
||||
maskCode,
|
||||
mergeTaskContext,
|
||||
} from './shared.js'
|
||||
|
||||
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
let { task, session } = await loadTaskSession(context.task, { includeQrImage })
|
||||
|
||||
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
|
||||
task = await syncKuaishouCloudRoleInfo(task)
|
||||
}
|
||||
|
||||
const syncedTask = session ? await syncTaskWithSession(task, session) : task
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) {
|
||||
const context = await getClaimContextByTaskId(taskId)
|
||||
const { task, session } = await loadTaskSession(context.task, { includeQrImage })
|
||||
const syncedTask = session ? await syncTaskWithSession(task, session) : task
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getClaimSessionSummary(token) {
|
||||
return getClaimSessionSummaryByContextLoader(() => getClaimContext(token))
|
||||
}
|
||||
|
||||
export async function getClaimSessionSummaryForAdminTask(taskId) {
|
||||
return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId))
|
||||
}
|
||||
|
||||
export async function confirmClaimRole(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertPublicClaimActionAllowed(context.task, 'confirm')
|
||||
|
||||
return finalizeClaimRoleConfirmation(context)
|
||||
}
|
||||
|
||||
export async function confirmClaimRoleForAdminTask(taskId) {
|
||||
const context = await getClaimContextByTaskId(taskId)
|
||||
|
||||
return finalizeClaimRoleConfirmation(context)
|
||||
}
|
||||
|
||||
export async function redeemClaimTask(token) {
|
||||
const context = await getClaimContext(token)
|
||||
assertTaskCanProceed(context.task)
|
||||
assertPublicClaimActionAllowed(context.task, 'redeem')
|
||||
|
||||
return finalizeClaimTaskRedeem(context)
|
||||
}
|
||||
|
||||
export async function redeemClaimTaskForAdminTask(taskId) {
|
||||
const context = await getClaimContextByTaskId(taskId)
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
return finalizeClaimTaskRedeem(context)
|
||||
}
|
||||
|
||||
export async function getClaimScreenshotPath(token) {
|
||||
const context = await getClaimContext(token)
|
||||
|
||||
if (context.task.screenshot_path) {
|
||||
return context.task.screenshot_path
|
||||
}
|
||||
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有兑换截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_screenshot_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id)
|
||||
}
|
||||
|
||||
async function getClaimSessionSummaryByContextLoader(loadContext) {
|
||||
const context = await loadContext()
|
||||
const { task, session } = await loadTaskSession(context.task, {
|
||||
includeQrImage: false,
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: null,
|
||||
})
|
||||
}
|
||||
|
||||
const syncedTask = await syncTaskWithSession(task, session)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async function finalizeClaimRoleConfirmation(context) {
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_session_not_created',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await getTencentBrowserSession(context.task.browser_session_id)
|
||||
const activityInfo = session.activityInfo || null
|
||||
|
||||
if (!activityInfo?.role?.ready) {
|
||||
throw createHttpError('当前角色信息还没有准备好', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_role_not_ready',
|
||||
})
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(context.task.id, {
|
||||
task_status: 'role_confirmed',
|
||||
nickname: String(activityInfo.nickname || ''),
|
||||
role_id: String(activityInfo.role.roleId || ''),
|
||||
role_name: String(activityInfo.role.roleName || ''),
|
||||
area: String(activityInfo.role.area || ''),
|
||||
partition_name: String(activityInfo.role.partition || ''),
|
||||
user_action_status: 'role_confirmed',
|
||||
role_confirmed_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async function finalizeClaimTaskRedeem(context) {
|
||||
if (!context.task.browser_session_id) {
|
||||
throw createHttpError('当前任务还没有创建浏览器会话', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_session_not_created',
|
||||
})
|
||||
}
|
||||
|
||||
if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') {
|
||||
throw createHttpError('当前任务还未确认角色,不能开始兑换', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_role_not_confirmed',
|
||||
})
|
||||
}
|
||||
|
||||
const inventoryItem = context.task.primary_inventory_item_id
|
||||
? await getInventoryItemById(context.task.primary_inventory_item_id)
|
||||
: null
|
||||
|
||||
if (
|
||||
!inventoryItem ||
|
||||
String(inventoryItem.status || '').trim() !== 'reserved' ||
|
||||
!String(inventoryItem.display_value || '').trim()
|
||||
) {
|
||||
throw createHttpError('当前任务没有可用的预占库存凭据', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_inventory_not_reserved',
|
||||
})
|
||||
}
|
||||
|
||||
await updateTask(context.task.id, {
|
||||
task_status: 'redeeming',
|
||||
delivery_status: 'processing',
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
try {
|
||||
const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem)
|
||||
const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult
|
||||
const finalRedeem = session.redeem?.final?.redeem || null
|
||||
const finishedAt = nowIso()
|
||||
const updatedTask = await updateTask(context.task.id, {
|
||||
task_status: 'redeemed',
|
||||
inventory_status: 'consumed',
|
||||
delivery_status: 'delivered',
|
||||
result_code: resolveTencentRedeemResultCode(finalRedeem, classification),
|
||||
result_message: classification.message,
|
||||
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
|
||||
artifacts_json: JSON.stringify(session.artifacts || {}),
|
||||
context_json: JSON.stringify(mergeTaskContext(context.task, {
|
||||
redeemResolution: {
|
||||
status: 'success',
|
||||
attempts,
|
||||
replacementCount: Math.max(0, attempts.length - 1),
|
||||
finishedAt,
|
||||
},
|
||||
})),
|
||||
redeemed_at: finishedAt,
|
||||
updated_at: finishedAt,
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt)
|
||||
await createTaskEvent(context.task.id, 'claim_redeem_completed', {
|
||||
inventoryItemId: deliveredInventoryItem.id,
|
||||
codeMasked: maskCode(deliveredInventoryItem.display_value),
|
||||
replacementCount: Math.max(0, attempts.length - 1),
|
||||
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
|
||||
resultMessage: classification.message,
|
||||
}, finishedAt)
|
||||
const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: context.order,
|
||||
task: updatedTask,
|
||||
trigger: 'claim_redeemed',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: autoDeliveryResult.task || updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
} catch (error) {
|
||||
const now = nowIso()
|
||||
const nextRetryCount = Number(context.task.attempt_count || 0) + 1
|
||||
const failureState = (
|
||||
error && typeof error === 'object'
|
||||
? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState
|
||||
: null
|
||||
) || {
|
||||
taskStatus: 'retry_pending',
|
||||
inventoryStatus: inventoryItem ? 'reserved' : 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
lastError: error instanceof Error ? error.message : String(error || ''),
|
||||
attempts: [],
|
||||
classification: null,
|
||||
}
|
||||
const failedTask = await updateTask(context.task.id, {
|
||||
task_status: failureState.taskStatus,
|
||||
inventory_status: failureState.inventoryStatus,
|
||||
delivery_status: failureState.deliveryStatus,
|
||||
result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '',
|
||||
result_message: String(failureState.lastError || ''),
|
||||
attempt_count: nextRetryCount,
|
||||
last_error: String(failureState.lastError || ''),
|
||||
context_json: JSON.stringify(mergeTaskContext(context.task, {
|
||||
redeemResolution: {
|
||||
status: 'failed',
|
||||
taskStatus: failureState.taskStatus,
|
||||
attempts: failureState.attempts || [],
|
||||
replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1),
|
||||
finishedAt: now,
|
||||
},
|
||||
})),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) {
|
||||
await notifyClaimRedeemNeedsAttention({
|
||||
task: failedTask,
|
||||
order: context.order,
|
||||
status: failureState.taskStatus,
|
||||
errorMessage: failureState.lastError,
|
||||
})
|
||||
}
|
||||
|
||||
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
|
||||
task: failedTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js'
|
||||
import { getOrderById } from '../../../repositories/order-repo.js'
|
||||
import { getOrderItemById } from '../../../repositories/order-item-repo.js'
|
||||
import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { CLAIM_TERMINAL_STATUSES } from './shared.js'
|
||||
|
||||
export async function getClaimContext(token) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createHttpError('缺少领取 token', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_claim_token',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await findClaimTokenByToken(normalized)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接无效或不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const task = await findTaskByClaimTokenId(claimToken.id)
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.status !== 'active') {
|
||||
throw createHttpError('领取链接当前不可用', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_inactive',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
|
||||
const expiredContext = await expireClaimContext(claimToken, task)
|
||||
|
||||
throw createHttpError('领取链接已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_expired',
|
||||
context: expiredContext,
|
||||
})
|
||||
}
|
||||
|
||||
const [order, orderItem] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getOrderItemById(task.order_item_id),
|
||||
])
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaimContextByTaskId(taskId) {
|
||||
const task = await getTaskById(Number(taskId))
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const claimTokenId = Number(task.primary_claim_token_id || 0)
|
||||
|
||||
if (!claimTokenId) {
|
||||
throw createHttpError('当前任务还没有领取链接', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_token_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await getClaimTokenById(claimTokenId)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const [order, orderItem] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getOrderItemById(task.order_item_id),
|
||||
])
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
async function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = await updateClaimToken(claimToken.id, {
|
||||
status: 'expired',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
let nextTask = task
|
||||
|
||||
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
||||
if (task.primary_inventory_item_id) {
|
||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
||||
}
|
||||
|
||||
nextTask = await updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
inventory_status: 'pending',
|
||||
user_action_status: 'expired',
|
||||
last_error: '领取链接已过期,预占库存项已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken: nextClaimToken,
|
||||
task: nextTask,
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import {
|
||||
closeTencentBrowserSession,
|
||||
createTencentBrowserSession,
|
||||
reloadTencentBrowserSession,
|
||||
} from '../../session/session.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { getClaimContext, getClaimContextByTaskId } from './context.js'
|
||||
import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js'
|
||||
import {
|
||||
assertTaskCanProceed,
|
||||
buildClaimDetailPayload,
|
||||
isRecoverableSessionError,
|
||||
normalizeClaimLoginType,
|
||||
} from './shared.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function createClaimSession(token: unknown, payload: JsonObject = {}) {
|
||||
return createClaimSessionWithContextLoader(
|
||||
() => getClaimContext(token),
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) {
|
||||
return createClaimSessionWithContextLoader(
|
||||
() => getClaimContextByTaskId(taskId),
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function reloadClaimSession(token: unknown) {
|
||||
return reloadClaimSessionWithContextLoader(() => getClaimContext(token))
|
||||
}
|
||||
|
||||
export async function reloadClaimSessionForAdminTask(taskId: unknown) {
|
||||
return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
|
||||
}
|
||||
|
||||
export async function closeClaimSession(token: unknown) {
|
||||
return closeClaimSessionWithContextLoader(() => getClaimContext(token))
|
||||
}
|
||||
|
||||
export async function closeClaimSessionForAdminTask(taskId: unknown) {
|
||||
return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
|
||||
}
|
||||
|
||||
async function createClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>, payload: JsonObject = {}) {
|
||||
const context = await loadContext()
|
||||
assertTaskCanProceed(context.task)
|
||||
const requestedLoginType = normalizeClaimLoginType(payload.loginType)
|
||||
const forceRecreate = Boolean(payload.forceRecreate)
|
||||
let task = context.task
|
||||
|
||||
if (task.browser_session_id) {
|
||||
const existing = await loadTaskSession(task, { includeQrImage: true })
|
||||
task = existing.task
|
||||
|
||||
if (existing.session) {
|
||||
const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type)
|
||||
|
||||
if (!forceRecreate && existingLoginType === requestedLoginType) {
|
||||
const syncedTask = await syncTaskWithSession(task, existing.session)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: existing.session,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await closeTencentBrowserSession(existing.session.sessionId)
|
||||
} catch (error) {
|
||||
if (!isRecoverableSessionError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
task = await clearTaskSession(task, {
|
||||
lastError: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const session = await createTencentBrowserSession({
|
||||
loginType: requestedLoginType,
|
||||
})
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: 'claimed',
|
||||
browser_session_id: session.sessionId,
|
||||
login_type: session.loginType,
|
||||
user_action_status: 'claimed',
|
||||
claimed_at: task.claimed_at || nowIso(),
|
||||
updated_at: nowIso(),
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async function reloadClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
|
||||
const context = await loadContext()
|
||||
assertTaskCanProceed(context.task)
|
||||
|
||||
const active = await loadTaskSession(context.task, {
|
||||
includeQrImage: true,
|
||||
})
|
||||
|
||||
if (!active.session) {
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: active.task,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: null,
|
||||
})
|
||||
}
|
||||
|
||||
const session = await reloadTencentBrowserSession(active.session.sessionId)
|
||||
const syncedTask = await syncTaskWithSession(active.task, session)
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: syncedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async function closeClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
|
||||
const context = await loadContext()
|
||||
assertTaskCanProceed(context.task)
|
||||
let task = context.task
|
||||
|
||||
if (!task.browser_session_id) {
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: null,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await closeTencentBrowserSession(task.browser_session_id)
|
||||
} catch (error) {
|
||||
if (!isRecoverableSessionError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
task = await clearTaskSession(task, {
|
||||
lastError: '',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session: null,
|
||||
})
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js'
|
||||
import { classifyTencentRedeemResult } from '../../session/session-redeem.js'
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import {
|
||||
invalidateReservedInventoryItem,
|
||||
markInventoryItemConsumed,
|
||||
} from '../../../repositories/inventory-repo.js'
|
||||
import { reserveInventoryForTask } from '../../order/inventory-service.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js'
|
||||
import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js'
|
||||
|
||||
type ClaimRedeemContext = {
|
||||
task: Pick<TaskRow, 'id' | 'browser_session_id'>
|
||||
orderItem: Pick<OrderItemRow, 'sku_code'>
|
||||
}
|
||||
|
||||
type TencentRedeemPayload = {
|
||||
[key: string]: unknown
|
||||
iRet?: unknown
|
||||
ret?: unknown
|
||||
}
|
||||
|
||||
type TencentRedeemSession = {
|
||||
[key: string]: unknown
|
||||
sessionId?: string
|
||||
artifacts?: {
|
||||
[key: string]: unknown
|
||||
hasScreenshot?: boolean
|
||||
} | null
|
||||
redeem?: {
|
||||
final?: {
|
||||
redeem?: TencentRedeemPayload | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type TencentRedeemClassification = {
|
||||
success: boolean
|
||||
outcome: string
|
||||
message: string
|
||||
retCode?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type RedeemAttemptSummary = {
|
||||
attempt: number
|
||||
inventoryItemId: number
|
||||
codeMasked: string
|
||||
credentialType: string
|
||||
outcome: string
|
||||
resultCode: string
|
||||
resultMessage: string
|
||||
}
|
||||
|
||||
type RedeemTaskState = {
|
||||
taskStatus: string
|
||||
inventoryStatus: string
|
||||
deliveryStatus: string
|
||||
lastError: string
|
||||
attempts: RedeemAttemptSummary[]
|
||||
classification: TencentRedeemClassification | null
|
||||
}
|
||||
|
||||
type RedeemTaskStateError = Error & {
|
||||
statusCode?: number
|
||||
errorCode?: string
|
||||
redeemTaskState: RedeemTaskState
|
||||
}
|
||||
|
||||
type RedeemTaskStateErrorPayload = {
|
||||
statusCode?: number
|
||||
errorCode?: string
|
||||
taskStatus?: string
|
||||
inventoryStatus?: string
|
||||
deliveryStatus?: string
|
||||
attempts?: RedeemAttemptSummary[]
|
||||
classification?: TencentRedeemClassification | null
|
||||
}
|
||||
|
||||
type RedeemClaimTaskDeps = {
|
||||
reloadTencentBrowserSession?: (sessionId: string) => Promise<unknown>
|
||||
redeemTencentBrowserSession?: (
|
||||
sessionId: string,
|
||||
payload: { code: string },
|
||||
) => Promise<TencentRedeemSession>
|
||||
classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification
|
||||
markInventoryItemConsumed?: (
|
||||
inventoryItemId: number | string,
|
||||
reason: string,
|
||||
consumedAt: string,
|
||||
) => Promise<InventoryItemRow | null>
|
||||
createTaskEvent?: (
|
||||
taskId: number | string,
|
||||
eventType: string,
|
||||
payload?: unknown,
|
||||
createdAt?: string,
|
||||
) => Promise<unknown>
|
||||
invalidateReservedInventoryItem?: (
|
||||
inventoryItemId: number | string,
|
||||
reason: string,
|
||||
updatedAt: string,
|
||||
) => Promise<InventoryItemRow | null>
|
||||
reserveInventoryForTask?: (payload: {
|
||||
skuCode: string
|
||||
taskId: number | string
|
||||
credentialType?: string
|
||||
roleKey?: string
|
||||
inventoryGroupCodes?: string[] | null
|
||||
}) => Promise<InventoryItemRow | null>
|
||||
nowIso?: () => string
|
||||
redeemReplacementLimit?: number
|
||||
}
|
||||
|
||||
type RedeemClaimTaskResult = {
|
||||
session: TencentRedeemSession
|
||||
inventoryItem: InventoryItemRow
|
||||
classification: TencentRedeemClassification
|
||||
attempts: RedeemAttemptSummary[]
|
||||
}
|
||||
|
||||
export async function redeemClaimTaskWithInventoryFallback(
|
||||
context: ClaimRedeemContext,
|
||||
initialInventoryItem: InventoryItemRow,
|
||||
): Promise<RedeemClaimTaskResult> {
|
||||
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
|
||||
}
|
||||
|
||||
export async function redeemClaimTaskWithInventoryFallbackWithDeps(
|
||||
context: ClaimRedeemContext,
|
||||
initialInventoryItem: InventoryItemRow,
|
||||
{
|
||||
reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession,
|
||||
redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession,
|
||||
classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult,
|
||||
markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed,
|
||||
createTaskEvent: createRedeemTaskEvent = createTaskEvent,
|
||||
invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem,
|
||||
reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask,
|
||||
nowIso: getNowIso = nowIso,
|
||||
redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT,
|
||||
}: RedeemClaimTaskDeps = {},
|
||||
): Promise<RedeemClaimTaskResult> {
|
||||
const attempts: RedeemAttemptSummary[] = []
|
||||
let currentInventoryItem = initialInventoryItem
|
||||
let shouldReloadBeforeNextAttempt = false
|
||||
|
||||
for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) {
|
||||
if (shouldReloadBeforeNextAttempt) {
|
||||
await reloadRedeemSession(context.task.browser_session_id)
|
||||
shouldReloadBeforeNextAttempt = false
|
||||
}
|
||||
|
||||
const session = await redeemSession(context.task.browser_session_id, {
|
||||
code: currentInventoryItem.display_value,
|
||||
})
|
||||
const finalRedeem = session.redeem?.final?.redeem || null
|
||||
const classification = classifyRedeemResult(finalRedeem)
|
||||
const attemptSummary = {
|
||||
attempt: attemptIndex,
|
||||
inventoryItemId: currentInventoryItem.id,
|
||||
codeMasked: maskCode(currentInventoryItem.display_value),
|
||||
credentialType: String(currentInventoryItem.credential_type || ''),
|
||||
outcome: classification.outcome,
|
||||
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
|
||||
resultMessage: classification.message,
|
||||
}
|
||||
|
||||
attempts.push(attemptSummary)
|
||||
|
||||
if (classification.success) {
|
||||
return {
|
||||
session,
|
||||
inventoryItem: currentInventoryItem,
|
||||
classification,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
if (classification.outcome === 'code_used') {
|
||||
const updatedAt = getNowIso()
|
||||
await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
|
||||
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', {
|
||||
inventoryItemId: currentInventoryItem.id,
|
||||
codeMasked: attemptSummary.codeMasked,
|
||||
resultCode: attemptSummary.resultCode,
|
||||
resultMessage: classification.message,
|
||||
}, updatedAt)
|
||||
} else if (classification.outcome === 'code_invalid') {
|
||||
const updatedAt = getNowIso()
|
||||
await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt)
|
||||
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
|
||||
inventoryItemId: currentInventoryItem.id,
|
||||
codeMasked: attemptSummary.codeMasked,
|
||||
resultCode: attemptSummary.resultCode,
|
||||
resultMessage: classification.message,
|
||||
}, updatedAt)
|
||||
} else {
|
||||
throw createRedeemTaskStateError(classification.message, {
|
||||
errorCode: 'claim_redeem_failed',
|
||||
taskStatus: 'retry_pending',
|
||||
inventoryStatus: 'reserved',
|
||||
deliveryStatus: 'pending',
|
||||
attempts,
|
||||
classification,
|
||||
})
|
||||
}
|
||||
|
||||
if (attemptIndex >= redeemReplacementLimit) {
|
||||
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
|
||||
errorCode: 'claim_redeem_replacement_limit_reached',
|
||||
taskStatus: 'retry_pending',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
attempts,
|
||||
classification,
|
||||
})
|
||||
}
|
||||
|
||||
const replacement = await reserveReplacementInventory({
|
||||
skuCode: context.orderItem.sku_code,
|
||||
taskId: context.task.id,
|
||||
credentialType: currentInventoryItem.credential_type || 'tencent_code',
|
||||
roleKey: 'primary_code',
|
||||
inventoryGroupCodes: currentInventoryItem.inventory_group_code
|
||||
? [String(currentInventoryItem.inventory_group_code).trim()]
|
||||
: null,
|
||||
})
|
||||
|
||||
if (!replacement || !String(replacement.display_value || '').trim()) {
|
||||
const exhaustedMessage = classification.outcome === 'code_used'
|
||||
? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试'
|
||||
: '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试'
|
||||
|
||||
throw createRedeemTaskStateError(exhaustedMessage, {
|
||||
errorCode: 'claim_inventory_replacement_exhausted',
|
||||
taskStatus: 'waiting_inventory',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
attempts,
|
||||
classification,
|
||||
})
|
||||
}
|
||||
|
||||
const replacedAt = getNowIso()
|
||||
await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
|
||||
previousInventoryItemId: currentInventoryItem.id,
|
||||
previousCodeMasked: attemptSummary.codeMasked,
|
||||
previousOutcome: classification.outcome,
|
||||
nextInventoryItemId: replacement.id,
|
||||
nextCodeMasked: maskCode(replacement.display_value),
|
||||
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
|
||||
inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(),
|
||||
}, replacedAt)
|
||||
currentInventoryItem = replacement
|
||||
shouldReloadBeforeNextAttempt = true
|
||||
}
|
||||
|
||||
throw createRedeemTaskStateError('兑换失败,请稍后重试', {
|
||||
errorCode: 'claim_redeem_failed',
|
||||
taskStatus: 'retry_pending',
|
||||
inventoryStatus: 'pending',
|
||||
deliveryStatus: 'pending',
|
||||
attempts,
|
||||
classification: null,
|
||||
})
|
||||
}
|
||||
|
||||
function createRedeemTaskStateError(
|
||||
message: string,
|
||||
payload: RedeemTaskStateErrorPayload = {},
|
||||
): RedeemTaskStateError {
|
||||
const error = createHttpError(message, {
|
||||
statusCode: payload.statusCode || 409,
|
||||
errorCode: payload.errorCode || 'claim_redeem_failed',
|
||||
}) as unknown as RedeemTaskStateError
|
||||
|
||||
error.redeemTaskState = {
|
||||
taskStatus: payload.taskStatus || 'retry_pending',
|
||||
inventoryStatus: payload.inventoryStatus || 'pending',
|
||||
deliveryStatus: payload.deliveryStatus || 'pending',
|
||||
lastError: String(message || ''),
|
||||
attempts: payload.attempts || [],
|
||||
classification: payload.classification || null,
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export function resolveTencentRedeemResultCode(
|
||||
finalRedeem: TencentRedeemPayload | null,
|
||||
classification: Pick<TencentRedeemClassification, 'retCode'> | null,
|
||||
): string {
|
||||
if (classification?.retCode != null) {
|
||||
return String(classification.retCode)
|
||||
}
|
||||
|
||||
const value = finalRedeem?.iRet ?? finalRedeem?.ret
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionSummary,
|
||||
} from '../../session/session.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { isRecoverableSessionError, parseTaskState } from './shared.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) {
|
||||
if (!task.browser_session_id) {
|
||||
return {
|
||||
task,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const session = includeQrImage
|
||||
? await getTencentBrowserSession(task.browser_session_id)
|
||||
: await getTencentBrowserSessionSummary(task.browser_session_id)
|
||||
|
||||
return {
|
||||
task,
|
||||
session,
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isRecoverableSessionError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const nextTask = await clearTaskSession(task, {
|
||||
lastError: '浏览器会话已失效,请重新初始化登录',
|
||||
})
|
||||
|
||||
return {
|
||||
task: nextTask,
|
||||
session: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncTaskWithSession(task: JsonObject, session: JsonObject) {
|
||||
const activityInfo = session.activityInfo || null
|
||||
const patch: JsonObject = {
|
||||
login_type: String(session.loginType || task.login_type || ''),
|
||||
updated_at: nowIso(),
|
||||
}
|
||||
|
||||
if (activityInfo?.nickname) {
|
||||
patch.nickname = String(activityInfo.nickname)
|
||||
}
|
||||
|
||||
if (activityInfo?.role?.ready) {
|
||||
patch.role_id = String(activityInfo.role.roleId || '')
|
||||
patch.role_name = String(activityInfo.role.roleName || '')
|
||||
patch.area = String(activityInfo.role.area || '')
|
||||
patch.partition_name = String(activityInfo.role.partition || '')
|
||||
}
|
||||
|
||||
if (task.task_status === 'link_generated') {
|
||||
patch.task_status = 'claimed'
|
||||
patch.user_action_status = 'claimed'
|
||||
patch.claimed_at = task.claimed_at || nowIso()
|
||||
}
|
||||
|
||||
if (session.review?.capturedAt) {
|
||||
patch.state_json = JSON.stringify({
|
||||
...parseTaskState(task),
|
||||
reviewScreenshotReady: true,
|
||||
reviewCapturedAt: String(session.review.capturedAt || ''),
|
||||
reviewRoleId: String(session.review.roleId || ''),
|
||||
reviewRoleName: String(session.review.roleName || ''),
|
||||
})
|
||||
}
|
||||
|
||||
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
|
||||
patch.screenshot_path = task.screenshot_path || ''
|
||||
}
|
||||
|
||||
return updateTask(task.id, patch)
|
||||
}
|
||||
|
||||
export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) {
|
||||
const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || ''))
|
||||
const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status
|
||||
const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status
|
||||
const patch = {
|
||||
task_status: nextTaskStatus,
|
||||
user_action_status: nextUserActionStatus,
|
||||
browser_session_id: '',
|
||||
login_type: '',
|
||||
nickname: '',
|
||||
role_id: '',
|
||||
role_name: '',
|
||||
area: '',
|
||||
partition_name: '',
|
||||
artifacts_json: '{}',
|
||||
state_json: '{}',
|
||||
role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at,
|
||||
last_error: String(lastError || ''),
|
||||
updated_at: nowIso(),
|
||||
}
|
||||
|
||||
return updateTask(task.id, patch)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js'
|
||||
|
||||
test('extractAgisoTradePayload preserves large integer order ids from raw json', () => {
|
||||
const payload = extractAgisoTradePayload({
|
||||
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
|
||||
})
|
||||
|
||||
assert.equal(payload.biz_order_id, '4502259793206016214')
|
||||
assert.equal(typeof payload.biz_order_id, 'string')
|
||||
})
|
||||
|
||||
test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => {
|
||||
const payload = extractAgisoTradePayload({
|
||||
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
|
||||
})
|
||||
|
||||
assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214')
|
||||
})
|
||||
|
||||
test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => {
|
||||
assert.equal(
|
||||
resolveAgisoTradePlatformOrderId({
|
||||
tid: '4502251008019011224',
|
||||
orders: [{ oid: '4502251008019011888' }],
|
||||
}),
|
||||
'4502251008019011224',
|
||||
)
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { parseJsonObject } from '../../utils/json.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export function extractAgisoTradePayload(body: unknown): JsonObject {
|
||||
const normalizedBody = normalizeRecord(body)
|
||||
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
|
||||
|
||||
if (rawJson) {
|
||||
return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true }))
|
||||
}
|
||||
|
||||
return normalizedBody
|
||||
}
|
||||
|
||||
export function resolveAgisoTradePlatformOrderId(payload: unknown): string {
|
||||
const normalizedPayload = normalizeRecord(payload)
|
||||
const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {}
|
||||
|
||||
return pickFirstNonEmpty([
|
||||
normalizedPayload.biz_order_id,
|
||||
normalizedPayload.Tid,
|
||||
normalizedPayload.tid,
|
||||
normalizedPayload.Oid,
|
||||
normalizedPayload.oid,
|
||||
normalizedPayload.order_id,
|
||||
normalizedPayload.orderId,
|
||||
firstItem.Oid,
|
||||
firstItem.oid,
|
||||
])
|
||||
}
|
||||
|
||||
export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] {
|
||||
const normalizedPayload = normalizeRecord(payload)
|
||||
const candidates = [
|
||||
normalizedPayload.items,
|
||||
normalizedPayload.Items,
|
||||
normalizedPayload.orders,
|
||||
normalizedPayload.Orders,
|
||||
normalizedPayload.order_list,
|
||||
normalizedPayload.OrderList,
|
||||
]
|
||||
|
||||
for (const current of candidates) {
|
||||
if (Array.isArray(current) && current.length > 0) {
|
||||
return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalizedPayload).length > 0) {
|
||||
return [normalizedPayload]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): JsonObject {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
filterLegacyOrderFulfillmentBindings,
|
||||
isKuaishouCloudFulfillmentBinding,
|
||||
} from './fulfillment-binding-config-service.js'
|
||||
|
||||
test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => {
|
||||
assert.equal(
|
||||
isKuaishouCloudFulfillmentBinding({
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
isKuaishouCloudFulfillmentBinding({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => {
|
||||
assert.deepEqual(
|
||||
filterLegacyOrderFulfillmentBindings([
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-a',
|
||||
},
|
||||
{
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: '91kaquan',
|
||||
skuCode: 'sku-b',
|
||||
},
|
||||
]),
|
||||
[
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-a',
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js'
|
||||
|
||||
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function getOrderFulfillmentBindingsFilePath() {
|
||||
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
|
||||
}
|
||||
|
||||
export function getOrderFulfillmentBindingConfigs() {
|
||||
return loadOrderFulfillmentBindingConfigsFromFile()
|
||||
}
|
||||
|
||||
export function getLegacyOrderFulfillmentBindingConfigs() {
|
||||
return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs())
|
||||
}
|
||||
|
||||
export function saveOrderFulfillmentBindingConfigs(rawValue) {
|
||||
return writeJsonFile(
|
||||
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
|
||||
rawValue,
|
||||
normalizeOrderFulfillmentBindingConfigs,
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrderFulfillmentBindingConfigsFromFile() {
|
||||
return readJsonFile(
|
||||
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
|
||||
[],
|
||||
normalizeOrderFulfillmentBindingConfigs,
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
|
||||
if (!Array.isArray(rawValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return rawValue
|
||||
.map((item) => normalizeOrderFulfillmentBinding(item))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) {
|
||||
return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item))
|
||||
}
|
||||
|
||||
export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) {
|
||||
return String(binding?.provider || '').trim() === '91kaquan'
|
||||
&& String(binding?.platform || '').trim() === 'kuaishou'
|
||||
}
|
||||
|
||||
function normalizeOrderFulfillmentBinding(rawValue) {
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
|
||||
const platform = String(rawValue.platform || '').trim()
|
||||
const shopId = String(rawValue.shopId || '').trim()
|
||||
const shopName = String(rawValue.shopName || '').trim()
|
||||
const skuCode = String(rawValue.skuCode || '').trim()
|
||||
const skuName = String(rawValue.skuName || '').trim()
|
||||
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
|
||||
const priority = normalizePriority(rawValue.priority)
|
||||
const enabled = rawValue.enabled !== false
|
||||
const config = normalizeJsonObject(rawValue.config)
|
||||
const match = normalizeOrderFulfillmentMatch(rawValue.match)
|
||||
|
||||
if (!skuCode || !match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
shopName,
|
||||
skuCode,
|
||||
skuName,
|
||||
profileKey,
|
||||
enabled,
|
||||
priority,
|
||||
config,
|
||||
match,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderFulfillmentMatch(rawValue) {
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
|
||||
const externalItemId = String(rawValue.externalItemId || '').trim()
|
||||
const externalSkuName = String(rawValue.externalSkuName || '').trim()
|
||||
|
||||
if (!externalSkuCode && !externalItemId && !externalSkuName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
externalSkuName,
|
||||
config: normalizeJsonObject(rawValue.config),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePriority(value) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 100
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(parsed))
|
||||
}
|
||||
|
||||
function normalizeJsonObject(value) {
|
||||
return isPlainObject(value) ? value : {}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
buildWebhookEventInput,
|
||||
executeAgisoTradeWebhookWithDeps,
|
||||
parseAgisoTradeRequest,
|
||||
resolveAgisoTradeIgnoreReason,
|
||||
shouldEnrichAgisoXianyuTrade,
|
||||
verifyAgisoSignatureWithSecret,
|
||||
} from './webhook-service.js'
|
||||
|
||||
function createParsedTrade(patch = {}) {
|
||||
return {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
platformRaw: 'xianyu',
|
||||
eventType: 'payment_success',
|
||||
eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840',
|
||||
signatureValid: true,
|
||||
platformOrderId: '4502259793206016214',
|
||||
shopId: 'shop-9',
|
||||
shopName: '咸鱼店铺',
|
||||
shopIdAliases: ['shop-9'],
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: 'buyer-1',
|
||||
buyerName: '测试买家',
|
||||
receiverContact: '',
|
||||
totalAmount: 2550,
|
||||
currency: 'CNY',
|
||||
paidAt: '2026-04-14T12:30:40.000Z',
|
||||
rawPayload: {},
|
||||
items: [
|
||||
{
|
||||
skuCode: 'dnf-cdk-a',
|
||||
skuName: 'DNF礼包',
|
||||
quantity: 1,
|
||||
spec: { title: 'DNF礼包' },
|
||||
},
|
||||
],
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => {
|
||||
const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
|
||||
const timestamp = '1713097840'
|
||||
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
|
||||
const sign = appSecret
|
||||
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
|
||||
: ''
|
||||
|
||||
const parsed = parseAgisoTradeRequest({
|
||||
query: {
|
||||
aopic: '1',
|
||||
timestamp,
|
||||
sign,
|
||||
},
|
||||
body: {
|
||||
json: rawJson,
|
||||
fromPlatform: 'xianyu',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(parsed.provider, 'agiso')
|
||||
assert.equal(parsed.platform, 'xianyu')
|
||||
assert.equal(parsed.eventType, 'payment_success')
|
||||
assert.equal(parsed.signatureValid, true)
|
||||
assert.equal(parsed.platformOrderId, '4502259793206016214')
|
||||
assert.equal(parsed.shopId, 'shop-9')
|
||||
assert.equal(parsed.shopName, '咸鱼店铺')
|
||||
assert.equal(parsed.payStatus, 'paid')
|
||||
assert.equal(parsed.orderStatus, 'paid')
|
||||
assert.equal(parsed.totalAmount, 2550)
|
||||
assert.equal(parsed.items.length, 1)
|
||||
assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a')
|
||||
assert.equal(parsed.items[0]?.quantity, 2)
|
||||
})
|
||||
|
||||
test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => {
|
||||
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
totalAmount: 0,
|
||||
buyerName: '',
|
||||
shopName: '',
|
||||
items: [],
|
||||
}), true)
|
||||
|
||||
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
totalAmount: 2550,
|
||||
buyerName: '测试买家',
|
||||
shopName: '咸鱼店铺',
|
||||
items: [
|
||||
{
|
||||
skuCode: 'dnf-cdk-a',
|
||||
spec: {
|
||||
title: 'DNF礼包',
|
||||
},
|
||||
},
|
||||
],
|
||||
}), false)
|
||||
|
||||
assert.equal(shouldEnrichAgisoXianyuTrade({
|
||||
provider: 'agiso',
|
||||
platform: 'taobao',
|
||||
totalAmount: 0,
|
||||
items: [],
|
||||
}), false)
|
||||
})
|
||||
|
||||
test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => {
|
||||
const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
|
||||
const timestamp = '1776096226'
|
||||
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
|
||||
const sign = appSecret
|
||||
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
|
||||
: ''
|
||||
|
||||
const parsed = parseAgisoTradeRequest({
|
||||
query: {
|
||||
aopic: '16',
|
||||
timestamp,
|
||||
sign,
|
||||
},
|
||||
body: {
|
||||
json: rawJson,
|
||||
fromPlatform: 'AldsIdle',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(parsed.platform, 'xianyu')
|
||||
assert.equal(parsed.eventType, 'buyer_confirm_goods')
|
||||
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
|
||||
})
|
||||
|
||||
test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => {
|
||||
const parsed = parseAgisoTradeRequest({
|
||||
query: {
|
||||
aopic: '1',
|
||||
timestamp: '1776096226',
|
||||
sign: '',
|
||||
},
|
||||
body: {
|
||||
json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}',
|
||||
fromPlatform: 'xianyu',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(parsed.eventType, 'buyer_confirm_goods')
|
||||
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
|
||||
})
|
||||
|
||||
test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => {
|
||||
const appSecret = 'unit-test-secret'
|
||||
const rawJson = '{"biz_order_id":"4502259793206016214"}'
|
||||
const timestamp = '1713097840'
|
||||
const documentedSign = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
|
||||
.digest('hex')
|
||||
const legacySign = crypto
|
||||
.createHash('md5')
|
||||
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
|
||||
.digest('hex')
|
||||
|
||||
assert.equal(
|
||||
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('buildWebhookEventInput preserves raw request payload for later replay', () => {
|
||||
const input = buildWebhookEventInput(
|
||||
{
|
||||
headers: { 'x-request-id': 'req-1' },
|
||||
query: { timestamp: '1713097840', sign: 'bad-sign' },
|
||||
body: { json: '{"biz_order_id":"4502259793206016214"}' },
|
||||
},
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-9',
|
||||
shopName: '咸鱼店铺',
|
||||
eventType: 'payment_success',
|
||||
eventKey: 'agiso:xianyu:4502259793206016214:payment_success',
|
||||
signatureValid: false,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(input.provider, 'agiso')
|
||||
assert.equal(input.platform, 'xianyu')
|
||||
assert.equal(input.eventType, 'payment_success')
|
||||
assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success')
|
||||
assert.equal(input.signatureValid, false)
|
||||
assert.equal(input.processed, false)
|
||||
assert.equal(input.processError, '')
|
||||
assert.equal(input.relatedOrderId, null)
|
||||
assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' })
|
||||
assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' })
|
||||
assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' })
|
||||
assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/)
|
||||
})
|
||||
|
||||
test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => {
|
||||
const updates = []
|
||||
|
||||
await assert.rejects(
|
||||
() => executeAgisoTradeWebhookWithDeps(
|
||||
createParsedTrade({ signatureValid: false }),
|
||||
701,
|
||||
{ requestId: 'req-invalid-signature' },
|
||||
{
|
||||
updateWebhookEvent: async (webhookEventId, patch) => {
|
||||
updates.push({ webhookEventId, patch })
|
||||
return { id: webhookEventId, ...patch }
|
||||
},
|
||||
hasConfiguredOrderItems: async () => {
|
||||
throw new Error('should not check configured items when signature is invalid')
|
||||
},
|
||||
},
|
||||
),
|
||||
/验签失败/,
|
||||
)
|
||||
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
webhookEventId: 701,
|
||||
patch: {
|
||||
processed: false,
|
||||
process_error: '验签失败',
|
||||
related_order_id: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => {
|
||||
const updates = []
|
||||
let upsertCalled = false
|
||||
|
||||
const result = await executeAgisoTradeWebhookWithDeps(
|
||||
createParsedTrade(),
|
||||
702,
|
||||
{ requestId: 'req-unconfigured' },
|
||||
{
|
||||
hasConfiguredOrderItems: async (input) => {
|
||||
assert.equal(input.provider, 'agiso')
|
||||
assert.equal(input.platform, 'xianyu')
|
||||
assert.equal(input.shopId, 'shop-9')
|
||||
assert.equal(input.items.length, 1)
|
||||
return false
|
||||
},
|
||||
updateWebhookEvent: async (webhookEventId, patch) => {
|
||||
updates.push({ webhookEventId, patch })
|
||||
return { id: webhookEventId, ...patch }
|
||||
},
|
||||
upsertOrderFromWebhook: async () => {
|
||||
upsertCalled = true
|
||||
return {}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(result.accepted, true)
|
||||
assert.equal(result.ignored, true)
|
||||
assert.equal(result.ignoreReason, 'unconfigured_product')
|
||||
assert.equal(result.orderId, null)
|
||||
assert.equal(upsertCalled, false)
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
webhookEventId: 702,
|
||||
patch: {
|
||||
processed: true,
|
||||
process_error: 'ignored_unconfigured_product',
|
||||
related_order_id: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => {
|
||||
const updates = []
|
||||
const enrichCalls = []
|
||||
const upsertCalls = []
|
||||
const parsed = createParsedTrade({
|
||||
totalAmount: 0,
|
||||
buyerName: '',
|
||||
shopName: '',
|
||||
items: [
|
||||
{
|
||||
skuCode: 'dnf-cdk-a',
|
||||
skuName: 'dnf-cdk-a',
|
||||
quantity: 1,
|
||||
spec: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeAgisoTradeWebhookWithDeps(
|
||||
parsed,
|
||||
703,
|
||||
{ requestId: 'req-success' },
|
||||
{
|
||||
hasConfiguredOrderItems: async () => true,
|
||||
enrichAgisoXianyuTradeOrder: async (input, options) => {
|
||||
enrichCalls.push({ input, options })
|
||||
return {
|
||||
parsed: {
|
||||
...input,
|
||||
totalAmount: 1990,
|
||||
buyerName: '补查买家',
|
||||
shopName: '补查店铺',
|
||||
items: [
|
||||
{
|
||||
skuCode: 'dnf-cdk-a',
|
||||
skuName: 'DNF礼包',
|
||||
quantity: 1,
|
||||
spec: { title: 'DNF礼包' },
|
||||
},
|
||||
],
|
||||
},
|
||||
enriched: true,
|
||||
reason: '',
|
||||
}
|
||||
},
|
||||
upsertOrderFromWebhook: async (input) => {
|
||||
upsertCalls.push(input)
|
||||
return {
|
||||
ignored: false,
|
||||
ignoreReason: '',
|
||||
order: { id: 801 },
|
||||
tasks: [
|
||||
{ id: 901, task_no: 'DT901', task_status: 'paid' },
|
||||
],
|
||||
messageDeliveries: [{ id: 1001 }],
|
||||
}
|
||||
},
|
||||
updateWebhookEvent: async (webhookEventId, patch) => {
|
||||
updates.push({ webhookEventId, patch })
|
||||
return { id: webhookEventId, ...patch }
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(enrichCalls.length, 1)
|
||||
assert.equal(enrichCalls[0].options.requestId, 'req-success')
|
||||
assert.equal(upsertCalls.length, 1)
|
||||
assert.equal(upsertCalls[0].totalAmount, 1990)
|
||||
assert.equal(upsertCalls[0].buyerName, '补查买家')
|
||||
assert.equal(result.accepted, true)
|
||||
assert.equal(result.ignored, false)
|
||||
assert.equal(result.enriched, true)
|
||||
assert.equal(result.orderId, 801)
|
||||
assert.equal(result.totalAmountFen, 1990)
|
||||
assert.equal(result.taskCount, 1)
|
||||
assert.deepEqual(result.tasks, [
|
||||
{
|
||||
taskId: 901,
|
||||
taskNo: 'DT901',
|
||||
status: 'paid',
|
||||
},
|
||||
])
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
webhookEventId: 703,
|
||||
patch: {
|
||||
processed: true,
|
||||
process_error: '',
|
||||
related_order_id: 801,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
## Agiso Platform Services
|
||||
|
||||
Keep Agiso-wide shared helpers in this directory.
|
||||
|
||||
Per-platform integrations live in subdirectories:
|
||||
|
||||
- `xianyu/`
|
||||
- `pdd/`
|
||||
- `taobao/`
|
||||
@@ -1,3 +0,0 @@
|
||||
## PDD
|
||||
|
||||
Put all Agiso PDD-specific service integrations here.
|
||||
@@ -1,181 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
|
||||
|
||||
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
|
||||
const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate']
|
||||
const AGISO_SHOP_CONFIG_KEYS = [
|
||||
'shopName',
|
||||
'accessToken',
|
||||
'messageTemplate',
|
||||
'autoDeliveryMessageTemplate',
|
||||
'appSecret',
|
||||
'apiVersion',
|
||||
'sendMessageEndpoint',
|
||||
'tradeDetailEndpoint',
|
||||
'tradeDetailApiVersion',
|
||||
'tradeDetailTimeoutMs',
|
||||
]
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
export type AgisoMessagingDefaults = {
|
||||
messageTemplate?: string
|
||||
autoDeliveryMessageTemplate?: string
|
||||
}
|
||||
|
||||
export type AgisoShopConfig = {
|
||||
enabled?: boolean
|
||||
shopName?: string
|
||||
accessToken?: string
|
||||
messageTemplate?: string
|
||||
autoDeliveryMessageTemplate?: string
|
||||
appSecret?: string
|
||||
apiVersion?: string
|
||||
sendMessageEndpoint?: string
|
||||
tradeDetailEndpoint?: string
|
||||
tradeDetailApiVersion?: string
|
||||
tradeDetailTimeoutMs?: string
|
||||
}
|
||||
|
||||
export type AgisoMessagingConfigDocument = {
|
||||
defaults: AgisoMessagingDefaults
|
||||
shops: Record<string, AgisoShopConfig>
|
||||
}
|
||||
|
||||
export function getAgisoShopsFilePath(): string {
|
||||
return AGISO_SHOPS_FILE_PATH
|
||||
}
|
||||
|
||||
export function getAgisoShopConfigMap(): Record<string, AgisoShopConfig> {
|
||||
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
|
||||
const fileConfig = loadAgisoMessagingConfigDocumentFromFile()
|
||||
|
||||
return {
|
||||
...envConfig,
|
||||
...fileConfig.shops,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null {
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
if (!normalizedShopId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getAgisoShopConfigMap()[normalizedShopId] || null
|
||||
}
|
||||
|
||||
export function getAgisoMessagingDefaults(): AgisoMessagingDefaults {
|
||||
return loadAgisoMessagingConfigDocumentFromFile().defaults
|
||||
}
|
||||
|
||||
export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument {
|
||||
const normalized = normalizeAgisoMessagingConfigDocument(rawValue)
|
||||
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
|
||||
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
||||
return normalized
|
||||
}
|
||||
|
||||
function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument {
|
||||
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
|
||||
return {
|
||||
defaults: {},
|
||||
shops: {},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
|
||||
const parsed = JSON.parse(rawText)
|
||||
return normalizeAgisoMessagingConfigDocument(parsed)
|
||||
} catch {
|
||||
return {
|
||||
defaults: {},
|
||||
shops: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgisoShopConfigMap(rawValue: unknown): Record<string, AgisoShopConfig> {
|
||||
const output: Record<string, AgisoShopConfig> = {}
|
||||
|
||||
const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : []
|
||||
for (const [shopId, config] of entries) {
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
if (!normalizedShopId || !isPlainObject(config)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const next: AgisoShopConfig = {}
|
||||
const enabled = normalizeBooleanLike(config.enabled)
|
||||
if (enabled !== null) {
|
||||
next.enabled = enabled
|
||||
}
|
||||
|
||||
for (const key of AGISO_SHOP_CONFIG_KEYS) {
|
||||
const value = String(config[key] || '').trim()
|
||||
if (value) {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
output[normalizedShopId] = next
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults {
|
||||
const output: AgisoMessagingDefaults = {}
|
||||
|
||||
if (!isPlainObject(rawValue)) {
|
||||
return output
|
||||
}
|
||||
|
||||
for (const key of AGISO_MESSAGING_DEFAULT_KEYS) {
|
||||
const value = String(rawValue[key] || '').trim()
|
||||
if (value) {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument {
|
||||
const normalizedValue = isPlainObject(rawValue) ? rawValue : {}
|
||||
const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults')
|
||||
|| Object.prototype.hasOwnProperty.call(normalizedValue, 'shops')
|
||||
|
||||
return {
|
||||
defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}),
|
||||
shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
## Taobao
|
||||
|
||||
Put all Agiso Taobao-specific service integrations here.
|
||||
@@ -1,3 +0,0 @@
|
||||
## Xianyu
|
||||
|
||||
Put all Agiso Xianyu-specific service integrations here.
|
||||
@@ -1,264 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import {
|
||||
hasAgisoAutoDeliverySucceeded,
|
||||
ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps,
|
||||
isOrderReadyForAgisoAutoDelivery,
|
||||
isAgisoAutoDeliverySuccess,
|
||||
resolveAgisoAutoDeliveryEndpoint,
|
||||
resolveAgisoAutoDeliveryErrorMessage,
|
||||
} from './auto-delivery-service.js'
|
||||
|
||||
const agisoOrder = {
|
||||
id: 101,
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shop_id: 'shop-auto-delivery-test',
|
||||
shop_name: '自动发货测试店',
|
||||
platform_order_id: 'P-AUTO-10001',
|
||||
}
|
||||
|
||||
function createDeliveredTask(patch = {}) {
|
||||
return {
|
||||
id: 201,
|
||||
order_id: agisoOrder.id,
|
||||
task_status: 'completed',
|
||||
delivery_status: 'delivered',
|
||||
context_json: '{}',
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
|
||||
assert.equal(isOrderReadyForAgisoAutoDelivery([]), false)
|
||||
assert.equal(isOrderReadyForAgisoAutoDelivery([
|
||||
{ delivery_status: 'delivered' },
|
||||
{ delivery_status: 'delivered' },
|
||||
]), true)
|
||||
assert.equal(isOrderReadyForAgisoAutoDelivery([
|
||||
{ delivery_status: 'delivered' },
|
||||
{ delivery_status: 'processing' },
|
||||
]), false)
|
||||
})
|
||||
|
||||
test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => {
|
||||
assert.equal(hasAgisoAutoDeliverySucceeded([
|
||||
{ context_json: {} },
|
||||
{ context_json: { agisoAutoDelivery: { status: 'success' } } },
|
||||
]), true)
|
||||
|
||||
assert.equal(hasAgisoAutoDeliverySucceeded([
|
||||
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
|
||||
]), false)
|
||||
})
|
||||
|
||||
test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => {
|
||||
assert.equal(hasAgisoAutoDeliverySucceeded([
|
||||
{ context_json: '{bad-json' },
|
||||
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) },
|
||||
]), true)
|
||||
|
||||
assert.equal(hasAgisoAutoDeliverySucceeded([
|
||||
{ context_json: '{bad-json' },
|
||||
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) },
|
||||
]), false)
|
||||
})
|
||||
|
||||
test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => {
|
||||
assert.equal(isAgisoAutoDeliverySuccess(200, {}), true)
|
||||
assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true)
|
||||
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true)
|
||||
assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false)
|
||||
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false)
|
||||
})
|
||||
|
||||
test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => {
|
||||
assert.equal(
|
||||
resolveAgisoAutoDeliveryErrorMessage(
|
||||
{ Error_Msg: '库存不足' },
|
||||
'raw body',
|
||||
400,
|
||||
),
|
||||
'库存不足',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveAgisoAutoDeliveryErrorMessage(
|
||||
{},
|
||||
'fallback raw body',
|
||||
400,
|
||||
),
|
||||
'fallback raw body',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => {
|
||||
assert.equal(
|
||||
resolveAgisoAutoDeliveryEndpoint(''),
|
||||
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'),
|
||||
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
)
|
||||
})
|
||||
|
||||
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => {
|
||||
const calls = {
|
||||
update: 0,
|
||||
fetch: 0,
|
||||
}
|
||||
|
||||
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
|
||||
order: agisoOrder,
|
||||
task: createDeliveredTask(),
|
||||
}, {
|
||||
listTasksByOrderId: async () => [
|
||||
createDeliveredTask(),
|
||||
createDeliveredTask({ id: 202, delivery_status: 'processing' }),
|
||||
],
|
||||
updateTask: async () => {
|
||||
calls.update += 1
|
||||
return null
|
||||
},
|
||||
fetch: async () => {
|
||||
calls.fetch += 1
|
||||
return { status: 200, text: async () => '{}' }
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.sent, false)
|
||||
assert.equal(result.skipped, true)
|
||||
assert.equal(result.reason, 'waiting_other_tasks')
|
||||
assert.equal(calls.update, 0)
|
||||
assert.equal(calls.fetch, 0)
|
||||
})
|
||||
|
||||
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => {
|
||||
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
|
||||
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
|
||||
const updates = []
|
||||
const events = []
|
||||
|
||||
runtimeConfig.platforms.agiso.appSecret = ''
|
||||
runtimeConfig.platforms.agiso.messaging.shops = {}
|
||||
|
||||
try {
|
||||
const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) })
|
||||
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
|
||||
order: {
|
||||
...agisoOrder,
|
||||
shop_id: 'missing-auto-delivery-config-shop',
|
||||
},
|
||||
task,
|
||||
trigger: 'unit_test',
|
||||
}, {
|
||||
listTasksByOrderId: async () => [task],
|
||||
updateTask: async (taskId, patch) => {
|
||||
updates.push({ taskId, patch })
|
||||
return { ...task, ...patch }
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
events.push({ taskId, eventType, payload, createdAt })
|
||||
return { id: 1 }
|
||||
},
|
||||
fetch: async () => {
|
||||
throw new Error('fetch should not be called without config')
|
||||
},
|
||||
nowIso: () => '2026-05-21T12:00:00.000Z',
|
||||
})
|
||||
|
||||
assert.equal(result.sent, false)
|
||||
assert.equal(result.skipped, true)
|
||||
assert.equal(result.reason, 'missing_config')
|
||||
assert.equal(updates.length, 1)
|
||||
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped')
|
||||
|
||||
const context = JSON.parse(updates[0].patch.context_json)
|
||||
assert.equal(context.existing, true)
|
||||
assert.equal(context.agisoAutoDelivery.status, 'skipped')
|
||||
assert.equal(context.agisoAutoDelivery.reason, 'missing_config')
|
||||
assert.deepEqual({
|
||||
hasEndpoint: context.agisoAutoDelivery.hasEndpoint,
|
||||
hasAccessToken: context.agisoAutoDelivery.hasAccessToken,
|
||||
hasAppSecret: context.agisoAutoDelivery.hasAppSecret,
|
||||
}, {
|
||||
hasEndpoint: true,
|
||||
hasAccessToken: false,
|
||||
hasAppSecret: false,
|
||||
})
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
|
||||
runtimeConfig.platforms.agiso.messaging.shops = originalShops
|
||||
}
|
||||
})
|
||||
|
||||
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => {
|
||||
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
|
||||
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
|
||||
const updates = []
|
||||
const events = []
|
||||
const messages = []
|
||||
|
||||
runtimeConfig.platforms.agiso.appSecret = 'runtime-secret'
|
||||
runtimeConfig.platforms.agiso.messaging.shops = {
|
||||
[agisoOrder.shop_id]: {
|
||||
accessToken: 'access-token',
|
||||
appSecret: 'shop-secret',
|
||||
apiVersion: '1',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const task = createDeliveredTask()
|
||||
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
|
||||
order: agisoOrder,
|
||||
task,
|
||||
trigger: 'unit_test',
|
||||
}, {
|
||||
listTasksByOrderId: async () => [task],
|
||||
fetch: async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }),
|
||||
}),
|
||||
confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({
|
||||
shipped: false,
|
||||
orderStatus: 2,
|
||||
shipTime: 0,
|
||||
reason: `not shipped: ${input.requestId}`,
|
||||
}),
|
||||
updateTask: async (taskId, patch) => {
|
||||
updates.push({ taskId, patch })
|
||||
return { ...task, ...patch }
|
||||
},
|
||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
||||
events.push({ taskId, eventType, payload, createdAt })
|
||||
return { id: 2 }
|
||||
},
|
||||
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => {
|
||||
messages.push(payload)
|
||||
return { sent: true }
|
||||
},
|
||||
nowIso: () => '2026-05-21T12:01:00.000Z',
|
||||
})
|
||||
|
||||
assert.equal(result.sent, false)
|
||||
assert.equal(result.skipped, false)
|
||||
assert.equal(result.reason, 'delivery_not_confirmed')
|
||||
assert.equal(messages.length, 0)
|
||||
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed')
|
||||
|
||||
const context = JSON.parse(updates[0].patch.context_json)
|
||||
assert.equal(context.agisoAutoDelivery.status, 'failed')
|
||||
assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed')
|
||||
assert.equal(context.agisoAutoDelivery.requestId, 'req-1')
|
||||
assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2)
|
||||
assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/)
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
|
||||
runtimeConfig.platforms.agiso.messaging.shops = originalShops
|
||||
}
|
||||
})
|
||||
@@ -1,640 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js'
|
||||
import { createTaskEvent } from '../../../../repositories/task-event-repo.js'
|
||||
import { getAgisoShopConfig } from '../shop-config-service.js'
|
||||
import { queryAgisoXianyuOrderDetail } from './order-detail-service.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js'
|
||||
import { parseJsonObject } from '../../../../utils/json.js'
|
||||
import { logWebhook } from '../../../../utils/logger.js'
|
||||
import { nowIso } from '../../../../utils/time.js'
|
||||
import type { TaskRow } from '../../../../types/repository-rows.js'
|
||||
|
||||
const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type AutoDeliveryOrder = {
|
||||
id?: number | string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shop_id?: string
|
||||
shop_name?: string
|
||||
platform_order_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type SupportedAgisoOrder = AutoDeliveryOrder & {
|
||||
id: number | string
|
||||
provider: 'agiso'
|
||||
platform: 'xianyu'
|
||||
platform_order_id: string
|
||||
}
|
||||
|
||||
type AutoDeliveryTask = Partial<TaskRow> & {
|
||||
id?: number | string
|
||||
delivery_status?: string
|
||||
task_status?: string
|
||||
context_json?: string | JsonObject | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type AutoDeliveryTaskWithId = AutoDeliveryTask & {
|
||||
id: number | string
|
||||
}
|
||||
|
||||
type EnsureAgisoXianyuAutoDeliveryInput = {
|
||||
order?: AutoDeliveryOrder | null
|
||||
task?: AutoDeliveryTask | null
|
||||
trigger?: string
|
||||
}
|
||||
|
||||
type AutoDeliveryStatus = 'success' | 'failed' | 'skipped'
|
||||
|
||||
type FetchResponseLike = {
|
||||
status: number
|
||||
text: () => Promise<string>
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<FetchResponseLike>
|
||||
|
||||
type AgisoAutoDeliveryConfirmInput = {
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
type AgisoAutoDeliveryConfirmResult = {
|
||||
shipped: boolean
|
||||
orderStatus: number
|
||||
shipTime: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
type AutoDeliveryMessageResult = {
|
||||
sent: boolean
|
||||
skipped?: boolean
|
||||
reason?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type EnsureAgisoXianyuAutoDeliveryDeps = {
|
||||
listTasksByOrderId?: (orderId: number | string) => Promise<AutoDeliveryTask[]>
|
||||
fetch?: FetchLike
|
||||
confirmAgisoXianyuAutoDeliveryShipped?: (
|
||||
input: AgisoAutoDeliveryConfirmInput,
|
||||
) => Promise<AgisoAutoDeliveryConfirmResult>
|
||||
updateTask?: (
|
||||
taskId: number | string,
|
||||
patch: { context_json: string; updated_at: string },
|
||||
) => Promise<AutoDeliveryTask | null>
|
||||
createTaskEvent?: (
|
||||
taskId: number | string,
|
||||
eventType: string,
|
||||
payload?: unknown,
|
||||
createdAt?: string,
|
||||
) => Promise<unknown>
|
||||
nowIso?: () => string
|
||||
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: (
|
||||
input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null },
|
||||
) => Promise<AutoDeliveryMessageResult>
|
||||
}
|
||||
|
||||
type PersistAgisoAutoDeliveryResultInput = {
|
||||
status: AutoDeliveryStatus
|
||||
trigger?: string
|
||||
reason?: string
|
||||
order?: AutoDeliveryOrder | null
|
||||
responseStatus?: number
|
||||
response?: unknown
|
||||
errorMessage?: string
|
||||
detail?: JsonObject
|
||||
}
|
||||
|
||||
type AgisoAutoDeliveryConfig = {
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
apiVersion: string
|
||||
appSecret: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order,
|
||||
task,
|
||||
trigger = 'task_delivered',
|
||||
}: EnsureAgisoXianyuAutoDeliveryInput = {}) {
|
||||
return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger })
|
||||
}
|
||||
|
||||
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
|
||||
order,
|
||||
task,
|
||||
trigger = 'task_delivered',
|
||||
}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
|
||||
const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId
|
||||
const sendRequest = deps.fetch || (fetch as FetchLike)
|
||||
const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped
|
||||
|
||||
if (!isAgisoXianyuOrder(order) || !task?.id) {
|
||||
return { sent: false, skipped: true, reason: 'not_supported', task }
|
||||
}
|
||||
|
||||
const currentTask = task as AutoDeliveryTaskWithId
|
||||
if (String(currentTask.delivery_status || '').trim() !== 'delivered') {
|
||||
return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask }
|
||||
}
|
||||
|
||||
const orderTasks = await listOrderTasks(order.id)
|
||||
if (hasAgisoAutoDeliverySucceeded(orderTasks)) {
|
||||
return { sent: false, skipped: true, reason: 'already_sent', task: currentTask }
|
||||
}
|
||||
|
||||
if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) {
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
taskStatuses: orderTasks.map((current) => ({
|
||||
taskId: current.id,
|
||||
taskStatus: current.task_status,
|
||||
deliveryStatus: current.delivery_status,
|
||||
})),
|
||||
})
|
||||
|
||||
return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask }
|
||||
}
|
||||
|
||||
const config = resolveAgisoXianyuAutoDeliveryConfig(order)
|
||||
if (!config.enabled) {
|
||||
return persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'skipped',
|
||||
trigger,
|
||||
reason: 'auto_delivery_disabled',
|
||||
order,
|
||||
}, deps)
|
||||
}
|
||||
|
||||
if (!config.endpoint || !config.accessToken || !config.appSecret) {
|
||||
return persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'skipped',
|
||||
trigger,
|
||||
reason: 'missing_config',
|
||||
order,
|
||||
detail: {
|
||||
hasEndpoint: Boolean(config.endpoint),
|
||||
hasAccessToken: Boolean(config.accessToken),
|
||||
hasAppSecret: Boolean(config.appSecret),
|
||||
},
|
||||
}, deps)
|
||||
}
|
||||
|
||||
const requestHeaders = buildRequestHeaders({
|
||||
accessToken: config.accessToken,
|
||||
apiVersion: config.apiVersion,
|
||||
})
|
||||
const requestBody = buildRequestBody({
|
||||
platformOrderId: String(order.platform_order_id || '').trim(),
|
||||
appSecret: config.appSecret,
|
||||
})
|
||||
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
endpoint: config.endpoint,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await sendRequest(config.endpoint, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
})
|
||||
const rawText = await response.text()
|
||||
const parsed = parseJsonObject(rawText) as JsonObject
|
||||
const success = isAgisoAutoDeliverySuccess(response.status, parsed)
|
||||
|
||||
if (success) {
|
||||
const requestId = String(parsed?.RequestId || '').trim()
|
||||
|
||||
// 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。
|
||||
const confirmResult = await confirmShipped({
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!confirmResult.shipped) {
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
responseStatus: response.status,
|
||||
requestId,
|
||||
confirmOrderStatus: confirmResult.orderStatus,
|
||||
confirmShipTime: confirmResult.shipTime,
|
||||
confirmReason: confirmResult.reason,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'failed',
|
||||
trigger,
|
||||
reason: 'delivery_not_confirmed',
|
||||
order,
|
||||
responseStatus: response.status,
|
||||
response: parsed,
|
||||
errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`,
|
||||
detail: {
|
||||
requestId,
|
||||
confirmOrderStatus: confirmResult.orderStatus,
|
||||
confirmShipTime: confirmResult.shipTime,
|
||||
},
|
||||
}, deps)
|
||||
}
|
||||
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
responseStatus: response.status,
|
||||
requestId,
|
||||
confirmOrderStatus: confirmResult.orderStatus,
|
||||
confirmShipTime: confirmResult.shipTime,
|
||||
})
|
||||
|
||||
const result = await persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'success',
|
||||
trigger,
|
||||
reason: '',
|
||||
order,
|
||||
responseStatus: response.status,
|
||||
response: parsed,
|
||||
detail: {
|
||||
requestId,
|
||||
confirmOrderStatus: confirmResult.orderStatus,
|
||||
confirmShipTime: confirmResult.shipTime,
|
||||
},
|
||||
}, deps)
|
||||
|
||||
// 发货确认成功后,发送自定义消息通知。
|
||||
await sendAutoDeliveryMessage({ order, task: currentTask }, deps)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status)
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
responseStatus: response.status,
|
||||
errorMessage,
|
||||
response: parsed,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'failed',
|
||||
trigger,
|
||||
reason: 'request_failed',
|
||||
order,
|
||||
responseStatus: response.status,
|
||||
response: parsed,
|
||||
errorMessage,
|
||||
}, deps)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败')
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: currentTask.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
errorMessage,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return persistAgisoAutoDeliveryResult(currentTask, {
|
||||
status: 'failed',
|
||||
trigger,
|
||||
reason: 'request_error',
|
||||
order,
|
||||
responseStatus: 0,
|
||||
response: {},
|
||||
errorMessage,
|
||||
}, deps)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean {
|
||||
return (Array.isArray(tasks) ? tasks : []).some((task) => {
|
||||
const context = parseTaskContext(task)
|
||||
const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {}
|
||||
return String(autoDelivery.status || '').trim() === 'success'
|
||||
})
|
||||
}
|
||||
|
||||
export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
return normalizedTasks.length > 0
|
||||
&& normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered')
|
||||
}
|
||||
|
||||
function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig {
|
||||
const baseConfig = runtimeConfig.platforms.agiso.autoDelivery
|
||||
const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {}
|
||||
|
||||
return {
|
||||
enabled: normalizeBooleanLike(baseConfig.enabled, true),
|
||||
endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint),
|
||||
apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1',
|
||||
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
|
||||
accessToken: String(shopConfig.accessToken || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestHeaders({
|
||||
accessToken,
|
||||
apiVersion,
|
||||
}: { accessToken: string; apiVersion: string }): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: String(apiVersion || '1').trim() || '1',
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({
|
||||
platformOrderId,
|
||||
appSecret,
|
||||
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
tid: String(platformOrderId || '').trim(),
|
||||
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params: Record<string, string>, appSecret: string): string {
|
||||
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
|
||||
let raw = String(appSecret || '').trim()
|
||||
|
||||
for (const [key, value] of sortedEntries) {
|
||||
raw += `${key}${value}`
|
||||
}
|
||||
|
||||
raw += String(appSecret || '').trim()
|
||||
|
||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizedPayload = payload as JsonObject
|
||||
if (normalizedPayload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(normalizedPayload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(normalizedPayload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveAgisoAutoDeliveryErrorMessage(
|
||||
payload: unknown,
|
||||
rawText: string,
|
||||
statusCode: number,
|
||||
): string {
|
||||
if (payload && typeof payload === 'object') {
|
||||
const normalizedPayload = payload as JsonObject
|
||||
for (const value of [
|
||||
normalizedPayload.Error_Msg,
|
||||
normalizedPayload.msg,
|
||||
normalizedPayload.message,
|
||||
normalizedPayload.error,
|
||||
]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string {
|
||||
return AGISO_DUMMY_SEND_ENDPOINT
|
||||
}
|
||||
|
||||
async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, {
|
||||
status,
|
||||
trigger,
|
||||
reason,
|
||||
order,
|
||||
responseStatus = 0,
|
||||
response = {},
|
||||
errorMessage = '',
|
||||
detail = {},
|
||||
}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
|
||||
const getNowIso = deps.nowIso || nowIso
|
||||
const patchTask = deps.updateTask || updateTask
|
||||
const insertTaskEvent = deps.createTaskEvent || createTaskEvent
|
||||
const now = getNowIso()
|
||||
const currentContext = parseTaskContext(task)
|
||||
const nextContext = {
|
||||
...currentContext,
|
||||
agisoAutoDelivery: {
|
||||
status,
|
||||
trigger: String(trigger || '').trim(),
|
||||
reason: String(reason || '').trim(),
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: Number(responseStatus || 0),
|
||||
errorMessage: String(errorMessage || '').trim(),
|
||||
response: isPlainObject(response) ? response : {},
|
||||
updatedAt: now,
|
||||
...detail,
|
||||
},
|
||||
}
|
||||
const updatedTask = await patchTask(task.id, {
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (status === 'success' || status === 'failed' || status === 'skipped') {
|
||||
await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, {
|
||||
trigger: String(trigger || '').trim(),
|
||||
reason: String(reason || '').trim(),
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: Number(responseStatus || 0),
|
||||
errorMessage: String(errorMessage || '').trim(),
|
||||
...detail,
|
||||
}, now)
|
||||
}
|
||||
|
||||
return {
|
||||
sent: status === 'success',
|
||||
skipped: status === 'skipped',
|
||||
reason: String(reason || '').trim(),
|
||||
errorMessage: String(errorMessage || '').trim(),
|
||||
responseStatus: Number(responseStatus || 0),
|
||||
response,
|
||||
task: updatedTask || task,
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject {
|
||||
const value = task?.context_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder {
|
||||
return String(order?.provider || '').trim() === 'agiso'
|
||||
&& String(order?.platform || '').trim() === 'xianyu'
|
||||
&& Boolean(String(order?.platform_order_id || '').trim())
|
||||
&& Number(order?.id || 0) > 0
|
||||
}
|
||||
|
||||
function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'undefined' || value === null || value === '') {
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。
|
||||
* 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过
|
||||
*/
|
||||
async function confirmAgisoXianyuAutoDeliveryShipped({
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
requestId = '',
|
||||
}: AgisoAutoDeliveryConfirmInput = {}): Promise<AgisoAutoDeliveryConfirmResult> {
|
||||
try {
|
||||
const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId })
|
||||
|
||||
if (detailResult.success && detailResult.shipped) {
|
||||
return {
|
||||
shipped: true,
|
||||
orderStatus: detailResult.orderStatus,
|
||||
shipTime: detailResult.shipTime,
|
||||
reason: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shipped: false,
|
||||
orderStatus: detailResult.orderStatus || 0,
|
||||
shipTime: detailResult.shipTime || 0,
|
||||
reason: detailResult.success ? 'order_not_shipped' : detailResult.reason,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败')
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', {
|
||||
requestId,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
errorMessage: message,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return {
|
||||
shipped: false,
|
||||
orderStatus: 0,
|
||||
shipTime: 0,
|
||||
reason: 'confirm_query_failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动发货成功后发送消息通知。
|
||||
*/
|
||||
async function sendAutoDeliveryMessage(
|
||||
{ order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {},
|
||||
deps: EnsureAgisoXianyuAutoDeliveryDeps = {},
|
||||
): Promise<AutoDeliveryMessageResult> {
|
||||
try {
|
||||
const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
|
||||
|| ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
|
||||
const result = await deliverMessage({ order, task })
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', {
|
||||
orderId: order?.id,
|
||||
taskId: task?.id,
|
||||
platformOrderId: order?.platform_order_id,
|
||||
sent: result.sent,
|
||||
skipped: result.skipped,
|
||||
reason: result.reason,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败')
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', {
|
||||
orderId: order?.id,
|
||||
taskId: task?.id,
|
||||
platformOrderId: order?.platform_order_id,
|
||||
errorMessage: message,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return { sent: false, skipped: true, reason: 'message_send_error' }
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import {
|
||||
deliverAgisoXianyuMessageForTaskWithDeps,
|
||||
normalizeAgisoMessageTemplate,
|
||||
renderAgisoAutoDeliveryMessage,
|
||||
} from './message-service.js'
|
||||
|
||||
test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => {
|
||||
const originalMessaging = runtimeConfig.platforms.agiso.messaging
|
||||
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
|
||||
|
||||
runtimeConfig.platforms.agiso.messaging = {
|
||||
...originalMessaging,
|
||||
enabled: true,
|
||||
sendMessageEndpoint: 'https://example.com/send',
|
||||
apiVersion: '1',
|
||||
accessToken: 'access-token',
|
||||
}
|
||||
runtimeConfig.platforms.agiso.appSecret = 'app-secret'
|
||||
|
||||
const calls = {
|
||||
find: [],
|
||||
create: 0,
|
||||
fetch: 0,
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deliverAgisoXianyuMessageForTaskWithDeps({
|
||||
order: {
|
||||
id: 15,
|
||||
platform: 'xianyu',
|
||||
shop_id: '2209880145223',
|
||||
platform_order_id: '4502280133178028841',
|
||||
},
|
||||
task: {
|
||||
id: 36,
|
||||
task_no: 'DT7af76e9b2438',
|
||||
},
|
||||
channel: 'agiso_im',
|
||||
messageContent: '测试消息',
|
||||
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
|
||||
}, {
|
||||
findLatestSuccessfulMessageDelivery: async (input) => {
|
||||
calls.find.push(input)
|
||||
return { id: 16, task_id: null }
|
||||
},
|
||||
createMessageDelivery: async () => {
|
||||
calls.create += 1
|
||||
return { id: 999 }
|
||||
},
|
||||
fetch: async () => {
|
||||
calls.fetch += 1
|
||||
return { status: 200, text: async () => '{}' }
|
||||
},
|
||||
})
|
||||
|
||||
assert.deepEqual(calls.find, [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: '2209880145223',
|
||||
platformOrderId: '4502280133178028841',
|
||||
channel: 'agiso_im',
|
||||
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
|
||||
},
|
||||
])
|
||||
assert.equal(calls.create, 0)
|
||||
assert.equal(calls.fetch, 0)
|
||||
assert.equal(result.sent, false)
|
||||
assert.equal(result.skipped, true)
|
||||
assert.equal(result.reason, 'already_sent')
|
||||
assert.equal(result.deliveryId, 16)
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.messaging = originalMessaging
|
||||
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
|
||||
}
|
||||
})
|
||||
|
||||
test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => {
|
||||
assert.equal(
|
||||
normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'),
|
||||
'第一行\n第二行\n第三行',
|
||||
)
|
||||
})
|
||||
|
||||
test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => {
|
||||
const message = renderAgisoAutoDeliveryMessage({
|
||||
order: {
|
||||
platform_order_id: '4502280133178028841',
|
||||
shop_id: '2209880145223',
|
||||
shop_name: '大锤号商',
|
||||
},
|
||||
task: {
|
||||
task_no: 'DT7af76e9b2438',
|
||||
},
|
||||
template: '订单 {platformOrderId}\\n结果:{resultMessage}',
|
||||
})
|
||||
|
||||
assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功')
|
||||
})
|
||||
@@ -1,539 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js'
|
||||
import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js'
|
||||
import {
|
||||
createMessageDelivery,
|
||||
findLatestSuccessfulMessageDelivery,
|
||||
updateMessageDelivery,
|
||||
} from '../../../../repositories/message-delivery-repo.js'
|
||||
import { nowIso } from '../../../../utils/time.js'
|
||||
|
||||
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
|
||||
const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type MessageOrder = {
|
||||
id?: number | string
|
||||
platform?: string
|
||||
shop_id?: string
|
||||
shop_name?: string
|
||||
platform_order_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type MessageTask = {
|
||||
id?: number | string
|
||||
task_no?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type AgisoMessagingConfig = {
|
||||
enabled?: boolean
|
||||
sendMessageEndpoint?: string
|
||||
accessToken?: string
|
||||
appSecret?: string
|
||||
apiVersion?: string
|
||||
messageTemplate?: string
|
||||
autoDeliveryMessageTemplate?: string
|
||||
shopName?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type EnsureClaimMessageInput = {
|
||||
order?: MessageOrder | null
|
||||
task?: MessageTask | null
|
||||
claimUrl?: string
|
||||
expiredAt?: string
|
||||
}
|
||||
|
||||
type EnsureAutoDeliveryMessageInput = {
|
||||
order?: MessageOrder | null
|
||||
task?: MessageTask | null
|
||||
}
|
||||
|
||||
type DeliverMessageInput = EnsureAutoDeliveryMessageInput & {
|
||||
channel?: string
|
||||
messageContent?: string
|
||||
claimUrl?: string
|
||||
}
|
||||
|
||||
type FetchResponseLike = {
|
||||
status: number
|
||||
text: () => Promise<string>
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<FetchResponseLike>
|
||||
|
||||
type MessageDeliveryLike = {
|
||||
id: number | string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type MessageDeliveryCreateInput = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
channel: string
|
||||
orderId?: number | string | null
|
||||
taskId?: number | string | null
|
||||
platformOrderId?: string
|
||||
recipientKey?: string
|
||||
messageContent: string
|
||||
claimUrl: string
|
||||
status: string
|
||||
requestUrl: string
|
||||
requestHeadersJson: string
|
||||
requestBodyJson: string
|
||||
responseStatus: number
|
||||
responseJson: string
|
||||
errorMessage: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type MessageDeliveryPatch = {
|
||||
status?: string
|
||||
response_status?: number
|
||||
response_json?: string
|
||||
error_message?: string
|
||||
sent_at?: string | null
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
type FindSuccessfulDeliveryInput = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
platformOrderId: string
|
||||
channel: string
|
||||
claimUrl: string
|
||||
}
|
||||
|
||||
type DeliverMessageDeps = {
|
||||
nowIso?: () => string
|
||||
findLatestSuccessfulMessageDelivery?: (
|
||||
input: FindSuccessfulDeliveryInput,
|
||||
) => Promise<MessageDeliveryLike | null>
|
||||
createMessageDelivery?: (
|
||||
input: MessageDeliveryCreateInput,
|
||||
) => Promise<MessageDeliveryLike | null>
|
||||
updateMessageDelivery?: (
|
||||
deliveryId: number | string,
|
||||
patch: MessageDeliveryPatch,
|
||||
) => Promise<MessageDeliveryLike | null>
|
||||
fetch?: FetchLike
|
||||
}
|
||||
|
||||
type RenderClaimMessageInput = EnsureClaimMessageInput & {
|
||||
template?: string
|
||||
shopName?: string
|
||||
}
|
||||
|
||||
type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & {
|
||||
template?: string
|
||||
shopName?: string
|
||||
}
|
||||
|
||||
type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & {
|
||||
template?: string
|
||||
claimUrl?: string
|
||||
expiredAt?: string
|
||||
resultMessage?: string
|
||||
}
|
||||
|
||||
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({
|
||||
order,
|
||||
task,
|
||||
claimUrl,
|
||||
expiredAt,
|
||||
}: EnsureClaimMessageInput = {}) {
|
||||
const config = resolveAgisoXianyuMessagingConfig(order)
|
||||
const messageContent = renderAgisoClaimMessage({
|
||||
order,
|
||||
task,
|
||||
claimUrl,
|
||||
expiredAt,
|
||||
template: config.messageTemplate,
|
||||
shopName: config.shopName,
|
||||
})
|
||||
|
||||
return deliverAgisoXianyuMessageForTask({
|
||||
order,
|
||||
task,
|
||||
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
|
||||
messageContent,
|
||||
claimUrl,
|
||||
})
|
||||
}
|
||||
|
||||
export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({
|
||||
order,
|
||||
task,
|
||||
}: EnsureAutoDeliveryMessageInput = {}) {
|
||||
const config = resolveAgisoXianyuMessagingConfig(order)
|
||||
const messageContent = renderAgisoAutoDeliveryMessage({
|
||||
order,
|
||||
task,
|
||||
template: config.autoDeliveryMessageTemplate,
|
||||
shopName: config.shopName,
|
||||
})
|
||||
|
||||
return deliverAgisoXianyuMessageForTask({
|
||||
order,
|
||||
task,
|
||||
channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL,
|
||||
messageContent,
|
||||
})
|
||||
}
|
||||
|
||||
async function deliverAgisoXianyuMessageForTask({
|
||||
order,
|
||||
task,
|
||||
channel,
|
||||
messageContent,
|
||||
claimUrl = '',
|
||||
}: DeliverMessageInput = {}) {
|
||||
return deliverAgisoXianyuMessageForTaskWithDeps({
|
||||
order,
|
||||
task,
|
||||
channel,
|
||||
messageContent,
|
||||
claimUrl,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deliverAgisoXianyuMessageForTaskWithDeps({
|
||||
order,
|
||||
task,
|
||||
channel,
|
||||
messageContent,
|
||||
claimUrl = '',
|
||||
}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) {
|
||||
const now = deps.nowIso || nowIso
|
||||
const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery
|
||||
const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery
|
||||
const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery
|
||||
const sendRequest = deps.fetch || (fetch as FetchLike)
|
||||
|
||||
if (!order || !task || !messageContent) {
|
||||
return { sent: false, skipped: true, reason: 'missing_message_context' }
|
||||
}
|
||||
|
||||
const config = resolveAgisoXianyuMessagingConfig(order)
|
||||
if (!config.enabled) {
|
||||
return { sent: false, skipped: true, reason: 'messaging_disabled' }
|
||||
}
|
||||
|
||||
const endpoint = String(config.sendMessageEndpoint || '').trim()
|
||||
const accessToken = String(config.accessToken || '').trim()
|
||||
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
|
||||
if (!endpoint || !accessToken || !appSecret) {
|
||||
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
|
||||
}
|
||||
|
||||
const successful = await findSuccessfulDelivery({
|
||||
provider: 'agiso',
|
||||
platform: String(order.platform || '').trim() || 'unknown',
|
||||
shopId: String(order.shop_id || '').trim(),
|
||||
platformOrderId: String(order.platform_order_id || '').trim(),
|
||||
channel,
|
||||
claimUrl: String(claimUrl || ''),
|
||||
})
|
||||
if (successful) {
|
||||
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
|
||||
}
|
||||
|
||||
const url = buildRequestUrl(endpoint)
|
||||
const requestBody = buildRequestBody({
|
||||
tid: String(order.platform_order_id || ''),
|
||||
msg: messageContent,
|
||||
appSecret,
|
||||
})
|
||||
const requestHeaders = buildRequestHeaders({
|
||||
accessToken,
|
||||
apiVersion: String(config.apiVersion || '1').trim() || '1',
|
||||
})
|
||||
const createdAt = now()
|
||||
const delivery = await insertMessageDelivery({
|
||||
provider: 'agiso',
|
||||
platform: String(order.platform || '').trim() || 'unknown',
|
||||
shopId: String(order.shop_id || '').trim(),
|
||||
shopName: String(order.shop_name || '').trim(),
|
||||
channel,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
recipientKey: order.platform_order_id,
|
||||
messageContent,
|
||||
claimUrl,
|
||||
status: 'pending',
|
||||
requestUrl: url,
|
||||
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
|
||||
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
|
||||
responseStatus: 0,
|
||||
responseJson: '{}',
|
||||
errorMessage: '',
|
||||
sentAt: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
if (!delivery) {
|
||||
return { sent: false, skipped: false, reason: 'delivery_create_failed' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRequest(url, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
})
|
||||
const rawText = await response.text()
|
||||
const parsed = safeParseJson(rawText)
|
||||
const success = isAgisoSendSuccess(response.status, parsed)
|
||||
const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status)
|
||||
const updated = await patchMessageDelivery(delivery.id, {
|
||||
status: success ? 'success' : 'failed',
|
||||
response_status: response.status,
|
||||
response_json: JSON.stringify(parsed ?? { rawText }),
|
||||
error_message: errorMessage,
|
||||
sent_at: success ? now() : null,
|
||||
updated_at: now(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: success,
|
||||
skipped: false,
|
||||
deliveryId: updated?.id || delivery.id,
|
||||
responseStatus: response.status,
|
||||
response: parsed ?? { rawText },
|
||||
errorMessage,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
|
||||
await patchMessageDelivery(delivery.id, {
|
||||
status: 'failed',
|
||||
response_status: 0,
|
||||
response_json: '{}',
|
||||
error_message: message,
|
||||
sent_at: null,
|
||||
updated_at: now(),
|
||||
})
|
||||
|
||||
return {
|
||||
sent: false,
|
||||
skipped: false,
|
||||
deliveryId: delivery.id,
|
||||
errorMessage: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig {
|
||||
const baseConfig = runtimeConfig.platforms.agiso.messaging
|
||||
const fileDefaults = getAgisoMessagingDefaults()
|
||||
const shopId = String(order?.shop_id || '').trim()
|
||||
const shopConfigs = getAgisoShopConfigMap()
|
||||
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
...fileDefaults,
|
||||
...shopConfig,
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestUrl(endpoint: string): string {
|
||||
return new URL(endpoint).toString()
|
||||
}
|
||||
|
||||
function buildRequestHeaders({
|
||||
accessToken,
|
||||
apiVersion,
|
||||
}: { accessToken: string; apiVersion: string }): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: apiVersion,
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({
|
||||
tid,
|
||||
msg,
|
||||
appSecret,
|
||||
}: { tid: string; msg: string; appSecret: string }): Record<string, string> {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const payload: Record<string, string> = {
|
||||
tid,
|
||||
msg,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params: Record<string, string>, appSecret: string): string {
|
||||
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
|
||||
let raw = appSecret
|
||||
|
||||
for (const [key, value] of sortedEntries) {
|
||||
raw += `${key}${value}`
|
||||
}
|
||||
|
||||
raw += appSecret
|
||||
|
||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
function renderAgisoClaimMessage({
|
||||
order,
|
||||
task,
|
||||
claimUrl,
|
||||
expiredAt,
|
||||
template,
|
||||
shopName = '',
|
||||
}: RenderClaimMessageInput): string {
|
||||
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|
||||
|| '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
|
||||
|
||||
return renderAgisoMessageTemplate({
|
||||
order,
|
||||
task,
|
||||
template: source,
|
||||
shopName,
|
||||
claimUrl,
|
||||
expiredAt: String(expiredAt || '尽快'),
|
||||
})
|
||||
}
|
||||
|
||||
export function renderAgisoAutoDeliveryMessage({
|
||||
order,
|
||||
task,
|
||||
template,
|
||||
shopName = '',
|
||||
}: RenderAutoDeliveryMessageInput): string {
|
||||
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|
||||
|| '您的订单 {platformOrderId} 已完成自动发货,请注意查收。'
|
||||
|
||||
return renderAgisoMessageTemplate({
|
||||
order,
|
||||
task,
|
||||
template: source,
|
||||
shopName,
|
||||
resultMessage: '自动发货成功',
|
||||
})
|
||||
}
|
||||
|
||||
function renderAgisoMessageTemplate({
|
||||
order,
|
||||
task,
|
||||
template,
|
||||
shopName = '',
|
||||
claimUrl = '',
|
||||
expiredAt = '',
|
||||
resultMessage = '',
|
||||
}: RenderMessageTemplateInput = {}): string {
|
||||
const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim()
|
||||
|
||||
return normalizeAgisoMessageTemplate(template)
|
||||
.replaceAll('{platformOrderId}', String(order?.platform_order_id || ''))
|
||||
.replaceAll('{taskNo}', String(task?.task_no || ''))
|
||||
.replaceAll('{shopName}', resolvedShopName)
|
||||
.replaceAll('{shopId}', String(order?.shop_id || ''))
|
||||
.replaceAll('{claimUrl}', String(claimUrl || ''))
|
||||
.replaceAll('{expiredAt}', String(expiredAt || ''))
|
||||
.replaceAll('{resultMessage}', String(resultMessage || ''))
|
||||
}
|
||||
|
||||
function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizedPayload = payload as JsonObject
|
||||
if (normalizedPayload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(normalizedPayload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(normalizedPayload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string {
|
||||
if (payload && typeof payload === 'object') {
|
||||
const normalizedPayload = payload as JsonObject
|
||||
for (const value of [
|
||||
normalizedPayload.Error_Msg,
|
||||
normalizedPayload.msg,
|
||||
normalizedPayload.message,
|
||||
normalizedPayload.error,
|
||||
]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
function safeParseJson(rawText: string): JsonObject | null {
|
||||
const normalized = String(rawText || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function maskHeadersForStorage(headers: Record<string, string>): Record<string, string> {
|
||||
const output = { ...headers }
|
||||
|
||||
if (output.Authorization) {
|
||||
output.Authorization = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function maskBodyForStorage(body: Record<string, string>): Record<string, string> {
|
||||
const output = { ...body }
|
||||
|
||||
if (output.sign) {
|
||||
output.sign = '[masked]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import {
|
||||
enrichAgisoXianyuTradeOrderWithDeps,
|
||||
queryAgisoXianyuOrderDetailWithDeps,
|
||||
resolveAgisoXianyuOrderDeliveryState,
|
||||
} from './order-detail-service.js'
|
||||
|
||||
test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => {
|
||||
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), {
|
||||
shipped: true,
|
||||
shipTime: 1713097840,
|
||||
orderStatus: 0,
|
||||
})
|
||||
|
||||
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), {
|
||||
shipped: true,
|
||||
shipTime: 0,
|
||||
orderStatus: 3,
|
||||
})
|
||||
|
||||
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), {
|
||||
shipped: false,
|
||||
shipTime: 0,
|
||||
orderStatus: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => {
|
||||
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
|
||||
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
|
||||
const calls = []
|
||||
|
||||
runtimeConfig.platforms.agiso.appSecret = ''
|
||||
runtimeConfig.platforms.agiso.messaging.shops = {
|
||||
'shop-detail-test': {
|
||||
accessToken: 'access-token',
|
||||
appSecret: 'shop-secret',
|
||||
tradeDetailEndpoint: 'https://example.com/detail',
|
||||
tradeDetailApiVersion: '2',
|
||||
tradeDetailTimeoutMs: '3000',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queryAgisoXianyuOrderDetailWithDeps({
|
||||
shopId: 'shop-detail-test',
|
||||
platformOrderId: 'P-DETAIL-10001',
|
||||
requestId: 'req-detail',
|
||||
}, {
|
||||
fetchWithTimeout: async (url, options, timeoutMs) => {
|
||||
calls.push({ url, options, timeoutMs })
|
||||
return {
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
IsSuccess: true,
|
||||
Data: {
|
||||
total_fee: '25.50',
|
||||
pay_time: '2026-04-14 20:30:40',
|
||||
ship_time: 1713097840,
|
||||
order_status: 3,
|
||||
item: {
|
||||
item_id: 'item-1',
|
||||
sku: 'dnf-cdk-a|商品名称:DNF礼包',
|
||||
quantity: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(result.responseStatus, 200)
|
||||
assert.equal(result.totalAmountFen, 2550)
|
||||
assert.equal(result.shipped, true)
|
||||
assert.equal(result.shipTime, 1713097840)
|
||||
assert.equal(result.orderStatus, 3)
|
||||
assert.equal(calls[0]?.url, 'https://example.com/detail')
|
||||
assert.equal(calls[0]?.timeoutMs, 3000)
|
||||
assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token')
|
||||
assert.equal(calls[0]?.options.headers.ApiVersion, '2')
|
||||
assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/)
|
||||
assert.match(String(calls[0]?.options.body), /sign=/)
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.messaging.shops = originalShops
|
||||
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
|
||||
}
|
||||
})
|
||||
|
||||
test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => {
|
||||
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
|
||||
|
||||
runtimeConfig.platforms.agiso.messaging.shops = {
|
||||
'shop-detail-test': {
|
||||
accessToken: 'access-token',
|
||||
appSecret: 'shop-secret',
|
||||
tradeDetailEndpoint: 'https://example.com/detail',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queryAgisoXianyuOrderDetailWithDeps({
|
||||
shopId: 'shop-detail-test',
|
||||
platformOrderId: 'P-DETAIL-10002',
|
||||
}, {
|
||||
fetchWithTimeout: async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
IsSuccess: false,
|
||||
Error_Msg: '订单不存在',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(result.reason, 'business_error')
|
||||
assert.equal(result.errorMessage, '订单不存在')
|
||||
assert.equal(result.responseStatus, 200)
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.messaging.shops = originalShops
|
||||
}
|
||||
})
|
||||
|
||||
test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => {
|
||||
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
|
||||
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
|
||||
let fetchCalled = false
|
||||
|
||||
runtimeConfig.platforms.agiso.messaging.shops = {}
|
||||
runtimeConfig.platforms.agiso.appSecret = ''
|
||||
|
||||
try {
|
||||
const result = await queryAgisoXianyuOrderDetailWithDeps({
|
||||
shopId: 'missing-shop',
|
||||
platformOrderId: 'P-DETAIL-10003',
|
||||
}, {
|
||||
fetchWithTimeout: async () => {
|
||||
fetchCalled = true
|
||||
return { status: 200, text: async () => '{}' }
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(result.reason, 'missing_config')
|
||||
assert.equal(fetchCalled, false)
|
||||
} finally {
|
||||
runtimeConfig.platforms.agiso.messaging.shops = originalShops
|
||||
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
|
||||
}
|
||||
})
|
||||
|
||||
test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => {
|
||||
const parsed = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-detail-test',
|
||||
shopName: '',
|
||||
platformOrderId: 'P-DETAIL-10004',
|
||||
totalAmount: 0,
|
||||
paidAt: null,
|
||||
buyerId: '',
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
rawPayload: {},
|
||||
items: [],
|
||||
}
|
||||
|
||||
const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, {
|
||||
queryAgisoXianyuOrderDetail: async (input) => {
|
||||
assert.deepEqual(input, {
|
||||
shopId: 'shop-detail-test',
|
||||
platformOrderId: 'P-DETAIL-10004',
|
||||
requestId: 'req-enrich',
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: '',
|
||||
errorMessage: '',
|
||||
responseStatus: 200,
|
||||
payload: {},
|
||||
detailPayload: {
|
||||
total_fee: '19.90',
|
||||
pay_time: '2026-04-14 20:30:40',
|
||||
buyer_id: 'buyer-1',
|
||||
buyer_name: '测试买家',
|
||||
receiver_mobile: '13800138000',
|
||||
shop_name: '详情店铺',
|
||||
items: [
|
||||
{
|
||||
item_id: 'item-1',
|
||||
sku: 'dnf-cdk-a|商品名称:DNF礼包',
|
||||
quantity: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
totalAmountFen: 1990,
|
||||
shipped: false,
|
||||
shipTime: 0,
|
||||
orderStatus: 2,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.enriched, true)
|
||||
assert.equal(result.totalAmountFen, 1990)
|
||||
assert.equal(result.parsed.totalAmount, 1990)
|
||||
assert.equal(result.parsed.buyerId, 'buyer-1')
|
||||
assert.equal(result.parsed.buyerName, '测试买家')
|
||||
assert.equal(result.parsed.receiverContact, '13800138000')
|
||||
assert.equal(result.parsed.shopName, '详情店铺')
|
||||
assert.equal(result.parsed.items.length, 1)
|
||||
assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a')
|
||||
assert.equal(result.parsed.items[0]?.quantity, 2)
|
||||
assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90')
|
||||
})
|
||||
@@ -1,803 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
||||
import { getAgisoShopConfig } from '../shop-config-service.js'
|
||||
import { logWebhook } from '../../../../utils/logger.js'
|
||||
import { nowIso } from '../../../../utils/time.js'
|
||||
import { parseAmountToFen } from '../../../../utils/money.js'
|
||||
import { parseJsonObject } from '../../../../utils/json.js'
|
||||
|
||||
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type AgisoParsedTrade = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
shopName?: string
|
||||
platformOrderId?: string
|
||||
totalAmount?: number
|
||||
paidAt?: string | null
|
||||
buyerId?: string
|
||||
buyerName?: string
|
||||
receiverContact?: string
|
||||
rawPayload?: JsonObject
|
||||
items?: AgisoOrderItem[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type AgisoOrderItem = {
|
||||
skuCode: string
|
||||
skuName: string
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
externalSkuName: string
|
||||
quantity: number
|
||||
spec: JsonObject
|
||||
snapshot: {
|
||||
externalItemId: string
|
||||
externalSkuCode: string
|
||||
externalSkuName: string
|
||||
}
|
||||
}
|
||||
|
||||
type AgisoDetailQueryInput = {
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
type FetchResponseLike = {
|
||||
status: number
|
||||
text: () => Promise<string>
|
||||
}
|
||||
|
||||
type FetchWithTimeout = (
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeoutMs: number,
|
||||
) => Promise<FetchResponseLike>
|
||||
|
||||
type AgisoDetailDeps = {
|
||||
fetchWithTimeout?: FetchWithTimeout
|
||||
queryAgisoXianyuOrderDetail?: (
|
||||
input: Required<AgisoDetailQueryInput>,
|
||||
) => Promise<AgisoDetailResult>
|
||||
}
|
||||
|
||||
type AgisoTradeDetailConfig = {
|
||||
endpoint: string
|
||||
apiVersion: string
|
||||
accessToken: string
|
||||
appSecret: string
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
type AgisoShopConfig = {
|
||||
accessToken?: string
|
||||
appSecret?: string
|
||||
tradeDetailEndpoint?: string
|
||||
tradeDetailApiVersion?: string
|
||||
tradeDetailTimeoutMs?: string | number
|
||||
}
|
||||
|
||||
type DeliveryState = {
|
||||
shipped: boolean
|
||||
shipTime: number
|
||||
orderStatus: number
|
||||
}
|
||||
|
||||
type AgisoDetailResult = {
|
||||
success: boolean
|
||||
reason: string
|
||||
errorMessage: string
|
||||
responseStatus: number
|
||||
payload: JsonObject
|
||||
detailPayload: JsonObject
|
||||
totalAmountFen: number
|
||||
shipped: boolean
|
||||
shipTime: number
|
||||
orderStatus: number
|
||||
}
|
||||
|
||||
type ExternalSkuDescriptor = {
|
||||
raw: string
|
||||
externalSkuCode: string
|
||||
externalSkuName: string
|
||||
}
|
||||
|
||||
export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) {
|
||||
return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId })
|
||||
}
|
||||
|
||||
export async function enrichAgisoXianyuTradeOrderWithDeps(
|
||||
parsed: AgisoParsedTrade,
|
||||
{ requestId = '' } = {},
|
||||
deps: AgisoDetailDeps = {},
|
||||
) {
|
||||
const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail
|
||||
|
||||
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
|
||||
return { parsed, enriched: false, reason: 'not_needed' }
|
||||
}
|
||||
|
||||
const detailResult = await queryOrderDetail({
|
||||
shopId: parsed.shopId,
|
||||
platformOrderId: parsed.platformOrderId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!detailResult.success) {
|
||||
return {
|
||||
parsed,
|
||||
enriched: false,
|
||||
reason: detailResult.reason,
|
||||
errorMessage: detailResult.errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const detailPayload = detailResult.detailPayload
|
||||
if (detailResult.totalAmountFen <= 0) {
|
||||
return {
|
||||
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
|
||||
enriched: false,
|
||||
reason: 'amount_still_missing',
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeEnrichedTrade(parsed, detailPayload)
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
|
||||
requestId,
|
||||
platform: parsed.platform,
|
||||
shopId: parsed.shopId,
|
||||
platformOrderId: parsed.platformOrderId,
|
||||
totalAmountFen: merged.totalAmount,
|
||||
paidAt: merged.paidAt,
|
||||
})
|
||||
|
||||
return {
|
||||
parsed: merged,
|
||||
enriched: true,
|
||||
totalAmountFen: merged.totalAmount,
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) {
|
||||
return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId })
|
||||
}
|
||||
|
||||
export async function queryAgisoXianyuOrderDetailWithDeps(
|
||||
{ shopId = '', platformOrderId = '', requestId = '' } = {},
|
||||
deps: AgisoDetailDeps = {},
|
||||
): Promise<AgisoDetailResult> {
|
||||
const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout
|
||||
const normalizedShopId = String(shopId || '').trim()
|
||||
const normalizedPlatformOrderId = String(platformOrderId || '').trim()
|
||||
const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId)
|
||||
|
||||
if (!config.endpoint || !config.accessToken || !config.appSecret) {
|
||||
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
hasEndpoint: Boolean(config.endpoint),
|
||||
hasAccessToken: Boolean(config.accessToken),
|
||||
hasAppSecret: Boolean(config.appSecret),
|
||||
}, { level: 'warn' })
|
||||
|
||||
return createFailedDetailResult('missing_config')
|
||||
}
|
||||
|
||||
const requestBody = buildRequestBody({
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
appSecret: config.appSecret,
|
||||
})
|
||||
const requestHeaders = buildRequestHeaders({
|
||||
accessToken: config.accessToken,
|
||||
apiVersion: config.apiVersion,
|
||||
})
|
||||
|
||||
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
endpoint: config.endpoint,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await requestWithTimeout(config.endpoint, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
}, config.timeoutMs)
|
||||
const rawText = await response.text()
|
||||
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject
|
||||
const detailPayload = extractAgisoDetailPayload(payload)
|
||||
const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload)
|
||||
const totalAmountFen = resolveTotalAmountFen(detailPayload)
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
responseStatus: response.status,
|
||||
errorMessage: message,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return createFailedDetailResult('http_error', {
|
||||
responseStatus: response.status,
|
||||
payload,
|
||||
detailPayload,
|
||||
errorMessage: message,
|
||||
totalAmountFen,
|
||||
delivery,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isAgisoDetailSuccess(payload)) {
|
||||
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
responseStatus: response.status,
|
||||
errorMessage: message,
|
||||
response: payload,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return createFailedDetailResult('business_error', {
|
||||
responseStatus: response.status,
|
||||
payload,
|
||||
detailPayload,
|
||||
errorMessage: message,
|
||||
totalAmountFen,
|
||||
delivery,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) {
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
responseStatus: response.status,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return createFailedDetailResult('empty_detail_payload', {
|
||||
responseStatus: response.status,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
if (totalAmountFen <= 0) {
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
responseStatus: response.status,
|
||||
detailKeys: Object.keys(detailPayload),
|
||||
}, { level: 'warn' })
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reason: '',
|
||||
errorMessage: '',
|
||||
responseStatus: response.status,
|
||||
payload: isPlainObject(payload) ? payload : {},
|
||||
detailPayload,
|
||||
totalAmountFen,
|
||||
shipped: delivery.shipped,
|
||||
shipTime: delivery.shipTime,
|
||||
orderStatus: delivery.orderStatus,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
|
||||
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
|
||||
requestId,
|
||||
platform: 'xianyu',
|
||||
shopId: normalizedShopId,
|
||||
platformOrderId: normalizedPlatformOrderId,
|
||||
errorMessage: message,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return createFailedDetailResult('request_failed', {
|
||||
errorMessage: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean {
|
||||
return parsed?.provider === 'agiso'
|
||||
&& parsed?.platform === 'xianyu'
|
||||
&& String(parsed?.platformOrderId || '').trim()
|
||||
&& Number(parsed?.totalAmount || 0) <= 0
|
||||
}
|
||||
|
||||
function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig {
|
||||
const baseConfig = runtimeConfig.platforms.agiso.tradeDetail
|
||||
const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig
|
||||
|
||||
return {
|
||||
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
|
||||
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
|
||||
accessToken: String(shopConfig.accessToken || '').trim(),
|
||||
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
|
||||
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestHeaders({
|
||||
accessToken,
|
||||
apiVersion,
|
||||
}: { accessToken: string; apiVersion: string }): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: apiVersion,
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({
|
||||
platformOrderId,
|
||||
appSecret,
|
||||
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const payload: Record<string, string> = {
|
||||
tid: String(platformOrderId || '').trim(),
|
||||
timestamp,
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params: Record<string, string>, appSecret: string): string {
|
||||
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
|
||||
let raw = appSecret
|
||||
|
||||
for (const [key, value] of sortedEntries) {
|
||||
raw += `${key}${value}`
|
||||
}
|
||||
|
||||
raw += appSecret
|
||||
|
||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
function extractAgisoDetailPayload(payload) {
|
||||
if (!isPlainObject(payload)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
|
||||
if (isPlainObject(payload[key])) {
|
||||
return payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export function resolveAgisoXianyuOrderDeliveryState(payload) {
|
||||
const shipTime = normalizeTimestampValue(pickFirstNonEmpty([
|
||||
payload?.ship_time,
|
||||
payload?.shipTime,
|
||||
payload?.ShipTime,
|
||||
payload?.delivery_time,
|
||||
payload?.deliveryTime,
|
||||
]))
|
||||
const orderStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([
|
||||
payload?.order_status,
|
||||
payload?.orderStatus,
|
||||
payload?.status,
|
||||
]),
|
||||
)
|
||||
|
||||
return {
|
||||
shipped: shipTime > 0 || orderStatus >= 3,
|
||||
shipTime,
|
||||
orderStatus,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTotalAmountFen(payload) {
|
||||
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
|
||||
payload.payment,
|
||||
payload.Payment,
|
||||
payload.post_fee,
|
||||
payload.postFee,
|
||||
payload.item?.price,
|
||||
]))
|
||||
|
||||
if (fenAmount > 0) {
|
||||
return fenAmount
|
||||
}
|
||||
|
||||
return parseAmountToFen(pickFirstNonEmpty([
|
||||
payload.total_fee,
|
||||
payload.totalFee,
|
||||
payload.TotalFee,
|
||||
payload.pay_fee,
|
||||
payload.payFee,
|
||||
payload.PayFee,
|
||||
payload.actual_fee,
|
||||
payload.actualFee,
|
||||
payload.ActualFee,
|
||||
payload.total_amount,
|
||||
payload.totalAmount,
|
||||
payload.Amount,
|
||||
payload.amount,
|
||||
]))
|
||||
}
|
||||
|
||||
function resolvePaidAt(payload, fallbackValue) {
|
||||
const providerPaidAt = normalizeProviderDateTime(
|
||||
pickFirstNonEmpty([
|
||||
payload.paid_at,
|
||||
payload.paidAt,
|
||||
payload.pay_time,
|
||||
payload.payTime,
|
||||
payload.PayTime,
|
||||
fallbackValue,
|
||||
]),
|
||||
)
|
||||
|
||||
if (providerPaidAt) {
|
||||
return providerPaidAt
|
||||
}
|
||||
|
||||
const rawStatus = normalizeInteger(
|
||||
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
|
||||
)
|
||||
|
||||
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
|
||||
return nowIso()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
|
||||
const mergedPayload = {
|
||||
...parsed.rawPayload,
|
||||
_agisoTradeDetail: detailPayload,
|
||||
}
|
||||
const totalAmountFen = resolveTotalAmountFen(detailPayload)
|
||||
const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items))
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
buyerId: pickFirstNonEmpty([
|
||||
parsed.buyerId,
|
||||
detailPayload.encryption_buyer_id,
|
||||
detailPayload.buyer_id,
|
||||
detailPayload.buyerId,
|
||||
detailPayload.BuyerId,
|
||||
detailPayload.BuyerOpenUid,
|
||||
detailPayload.buyer_open_uid,
|
||||
]),
|
||||
buyerName: pickFirstNonEmpty([
|
||||
parsed.buyerName,
|
||||
detailPayload.buyer_name,
|
||||
detailPayload.buyerName,
|
||||
detailPayload.BuyerName,
|
||||
detailPayload.buyer_nick,
|
||||
detailPayload.nick,
|
||||
detailPayload.BuyerNick,
|
||||
]),
|
||||
receiverContact: pickFirstNonEmpty([
|
||||
parsed.receiverContact,
|
||||
detailPayload.receiver_contact,
|
||||
detailPayload.receiverContact,
|
||||
detailPayload.receiver_mobile,
|
||||
detailPayload.receiverMobile,
|
||||
detailPayload.mobile,
|
||||
detailPayload.phone,
|
||||
]),
|
||||
shopName: pickFirstNonEmpty([
|
||||
parsed.shopName,
|
||||
detailPayload.shop_name,
|
||||
detailPayload.shopName,
|
||||
detailPayload.ShopName,
|
||||
detailPayload.seller_name,
|
||||
detailPayload.sellerName,
|
||||
detailPayload.seller_nick,
|
||||
detailPayload.sellerNick,
|
||||
detailPayload.SellerNick,
|
||||
]),
|
||||
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
|
||||
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
|
||||
rawPayload: mergedPayload,
|
||||
items: mergedItems,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderItems(payload, fallbackItems = []) {
|
||||
const candidates = [
|
||||
payload.item ? [payload.item] : null,
|
||||
payload.items,
|
||||
payload.Items,
|
||||
payload.orders,
|
||||
payload.Orders,
|
||||
payload.order_list,
|
||||
payload.OrderList,
|
||||
]
|
||||
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return Array.isArray(fallbackItems) ? fallbackItems : []
|
||||
}
|
||||
|
||||
return items.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
const skuDescriptor = parseExternalSkuDescriptor(
|
||||
pickFirstNonEmpty([
|
||||
source.sku,
|
||||
source.Sku,
|
||||
payload.sku,
|
||||
payload.Sku,
|
||||
]),
|
||||
)
|
||||
const skuCode = pickFirstNonEmpty([
|
||||
skuDescriptor.externalSkuCode,
|
||||
source.OuterSkuId,
|
||||
source.outerSkuId,
|
||||
source.outer_sku_id,
|
||||
source.OuterIid,
|
||||
source.outerIid,
|
||||
source.outer_iid,
|
||||
source.sku_code,
|
||||
source.skuCode,
|
||||
source.goods_sku,
|
||||
source.item_id,
|
||||
source.itemId,
|
||||
source.goods_id,
|
||||
source.goodsId,
|
||||
source.NumIid,
|
||||
source.num_iid,
|
||||
payload.item_id,
|
||||
payload.itemId,
|
||||
])
|
||||
const externalItemId = pickFirstNonEmpty([
|
||||
source.item_id,
|
||||
source.itemId,
|
||||
source.goods_id,
|
||||
source.goodsId,
|
||||
payload.item_id,
|
||||
payload.itemId,
|
||||
])
|
||||
const externalSkuName = pickFirstNonEmpty([
|
||||
skuDescriptor.externalSkuName,
|
||||
source.Title,
|
||||
source.title,
|
||||
source.sku_name,
|
||||
source.skuName,
|
||||
source.goods_name,
|
||||
source.goodsName,
|
||||
skuCode,
|
||||
])
|
||||
|
||||
return {
|
||||
skuCode,
|
||||
skuName: externalSkuName,
|
||||
externalItemId,
|
||||
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||
externalSkuName,
|
||||
quantity: Math.max(
|
||||
1,
|
||||
normalizeInteger(
|
||||
pickFirstNonEmpty([
|
||||
source.quantity,
|
||||
source.num,
|
||||
source.Num,
|
||||
source.buy_amount,
|
||||
payload.quantity,
|
||||
payload.num,
|
||||
payload.Num,
|
||||
]),
|
||||
) || 1,
|
||||
),
|
||||
spec: source,
|
||||
snapshot: {
|
||||
externalItemId,
|
||||
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
|
||||
externalSkuName,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseExternalSkuDescriptor(rawValue) {
|
||||
const raw = String(rawValue || '').trim()
|
||||
if (!raw) {
|
||||
return {
|
||||
raw,
|
||||
externalSkuCode: '',
|
||||
externalSkuName: '',
|
||||
}
|
||||
}
|
||||
|
||||
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
|
||||
let externalSkuCode = ''
|
||||
let externalSkuName = ''
|
||||
|
||||
for (const part of parts) {
|
||||
if (!externalSkuCode && !part.includes(':') && !part.includes(':')) {
|
||||
externalSkuCode = part
|
||||
continue
|
||||
}
|
||||
|
||||
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||
if (separatorIndex >= 0) {
|
||||
const label = part.slice(0, separatorIndex).trim()
|
||||
const value = part.slice(separatorIndex + 1).trim()
|
||||
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
|
||||
externalSkuName = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!externalSkuCode && parts[0]) {
|
||||
externalSkuCode = parts[0]
|
||||
}
|
||||
|
||||
if (!externalSkuName) {
|
||||
externalSkuName = parts
|
||||
.map((part) => {
|
||||
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':'))
|
||||
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
|
||||
})
|
||||
.find(Boolean) || ''
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
externalSkuCode,
|
||||
externalSkuName,
|
||||
}
|
||||
}
|
||||
|
||||
function isAgisoDetailSuccess(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (payload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.success) === 1 || payload.success === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeoutMs) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderDateTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^\d{10,13}$/.test(normalized)) {
|
||||
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
|
||||
|
||||
if (Number.isFinite(timestamp)) {
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const isoLike = normalized.replace(' ', 'T')
|
||||
const parsed = Date.parse(isoLike)
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Date(parsed).toISOString()
|
||||
}
|
||||
|
||||
function normalizeInteger(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : 0
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
|
||||
}
|
||||
|
||||
function normalizeFenInteger(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
|
||||
}
|
||||
|
||||
function normalizeTimestampValue(value) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
|
||||
}
|
||||
|
||||
function createFailedDetailResult(reason, {
|
||||
responseStatus = 0,
|
||||
payload = {},
|
||||
detailPayload = {},
|
||||
errorMessage = '',
|
||||
totalAmountFen = 0,
|
||||
delivery = { shipped: false, shipTime: 0, orderStatus: 0 },
|
||||
} = {}) {
|
||||
return {
|
||||
success: false,
|
||||
reason: String(reason || '').trim(),
|
||||
errorMessage: String(errorMessage || '').trim(),
|
||||
responseStatus: Number(responseStatus || 0),
|
||||
payload: isPlainObject(payload) ? payload : {},
|
||||
detailPayload: isPlainObject(detailPayload) ? detailPayload : {},
|
||||
totalAmountFen: Number(totalAmountFen || 0),
|
||||
shipped: Boolean(delivery?.shipped),
|
||||
shipTime: Number(delivery?.shipTime || 0),
|
||||
orderStatus: Number(delivery?.orderStatus || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values) {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
type OcrAction = 'recognize' | 'batch'
|
||||
|
||||
type OcrRequestPayload = Record<string, unknown>
|
||||
|
||||
type OcrHttpResult = Record<string, any> & {
|
||||
code: number
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
||||
return callOcrViaHttp('recognize', payload)
|
||||
}
|
||||
|
||||
export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
||||
return callOcrViaHttp('recognize', payload)
|
||||
}
|
||||
|
||||
export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
||||
return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 })
|
||||
}
|
||||
|
||||
export async function warmupOcrService(): Promise<void> {
|
||||
await warmupOcrViaHttp()
|
||||
}
|
||||
|
||||
export async function closeOcrService(): Promise<void> {
|
||||
// HTTP 模式无持久连接,无需关闭。
|
||||
}
|
||||
|
||||
// ── HTTP Call ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function callOcrViaHttp(
|
||||
action: OcrAction,
|
||||
payload: OcrRequestPayload,
|
||||
{ timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
||||
): Promise<OcrHttpResult> {
|
||||
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务')
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/ocr/${action}`
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||||
throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`)
|
||||
}
|
||||
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OCR 服务返回错误:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||||
throw new Error('OCR 服务返回了无效响应')
|
||||
}
|
||||
const ocrResult = result as OcrHttpResult
|
||||
if (ocrResult.code !== 0) {
|
||||
throw new Error(`OCR 服务返回错误:${ocrResult.msg || '未知错误'}`)
|
||||
}
|
||||
|
||||
return ocrResult
|
||||
}
|
||||
|
||||
async function warmupOcrViaHttp(): Promise<void> {
|
||||
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查')
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'TimeoutError') {
|
||||
throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`)
|
||||
}
|
||||
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`)
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import { extractHostState, reloadActivityPageForPresentation } from './session-page.js'
|
||||
import { shouldRefreshLoggedInPresentation } from './session-state.js'
|
||||
|
||||
type BrowserPageLike = {
|
||||
evaluate: <T = any>(callback: any) => Promise<T>
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
}
|
||||
|
||||
type HostState = {
|
||||
loginText?: string
|
||||
unloginVisible?: boolean
|
||||
loginedVisible?: boolean
|
||||
}
|
||||
|
||||
export type ActivityInfo = {
|
||||
nickname: string
|
||||
role: {
|
||||
ready: boolean
|
||||
roleName: string
|
||||
roleId: string
|
||||
area: string
|
||||
partition: string
|
||||
platId: string
|
||||
md5str: string
|
||||
checkparam: string
|
||||
}
|
||||
form: {
|
||||
cdkeyInputId: string
|
||||
cdkeyValue: string
|
||||
verifyInputId: string
|
||||
verifyValue: string
|
||||
verifyImgId: string
|
||||
submitId: string
|
||||
}
|
||||
verify: {
|
||||
visible: boolean
|
||||
src: string
|
||||
naturalWidth: number
|
||||
naturalHeight: number
|
||||
}
|
||||
popup: {
|
||||
visible: boolean
|
||||
text: string
|
||||
detail: string
|
||||
}
|
||||
}
|
||||
|
||||
type SessionForPresentation = {
|
||||
page: BrowserPageLike
|
||||
presentationSyncAttempts: number
|
||||
}
|
||||
|
||||
type EnsureLoggedInPresentationOptions = {
|
||||
hostState?: HostState | null
|
||||
credentialReady?: boolean
|
||||
activityUrl?: string
|
||||
extractHostState?: (page: BrowserPageLike) => Promise<HostState>
|
||||
reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise<unknown>
|
||||
shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean
|
||||
}
|
||||
|
||||
type EnsureActivityInfoReadyOptions = {
|
||||
triggerRender?: boolean
|
||||
timeoutMs?: number
|
||||
extractActivityInfo?: (page: BrowserPageLike) => Promise<ActivityInfo>
|
||||
triggerActivityRoleRender?: (page: BrowserPageLike) => Promise<unknown>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
renderCdkey?: () => void
|
||||
roleData?: Record<string, unknown>
|
||||
isRole?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyActivityInfo(): ActivityInfo {
|
||||
return {
|
||||
nickname: '',
|
||||
role: {
|
||||
ready: false,
|
||||
roleName: '',
|
||||
roleId: '',
|
||||
area: '',
|
||||
partition: '',
|
||||
platId: '',
|
||||
md5str: '',
|
||||
checkparam: '',
|
||||
},
|
||||
form: {
|
||||
cdkeyInputId: '',
|
||||
cdkeyValue: '',
|
||||
verifyInputId: '',
|
||||
verifyValue: '',
|
||||
verifyImgId: '',
|
||||
submitId: '',
|
||||
},
|
||||
verify: {
|
||||
visible: false,
|
||||
src: '',
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
},
|
||||
popup: {
|
||||
visible: false,
|
||||
text: '',
|
||||
detail: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureLoggedInPresentation(
|
||||
session: SessionForPresentation,
|
||||
{
|
||||
hostState = null,
|
||||
credentialReady = false,
|
||||
activityUrl = '',
|
||||
extractHostState: extractHostStateImpl = extractHostState,
|
||||
reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation,
|
||||
shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation,
|
||||
}: EnsureLoggedInPresentationOptions = {},
|
||||
): Promise<{ reloaded: boolean; hostState: HostState }> {
|
||||
let nextHostState = hostState || (await extractHostStateImpl(session.page))
|
||||
|
||||
if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) {
|
||||
return {
|
||||
reloaded: false,
|
||||
hostState: nextHostState,
|
||||
}
|
||||
}
|
||||
|
||||
if (session.presentationSyncAttempts >= 2) {
|
||||
return {
|
||||
reloaded: false,
|
||||
hostState: nextHostState,
|
||||
}
|
||||
}
|
||||
|
||||
session.presentationSyncAttempts += 1
|
||||
await reloadActivityPageForPresentationImpl(session.page, activityUrl)
|
||||
nextHostState = await extractHostStateImpl(session.page)
|
||||
|
||||
return {
|
||||
reloaded: true,
|
||||
hostState: nextHostState,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureActivityInfoReady(
|
||||
page: BrowserPageLike,
|
||||
{
|
||||
triggerRender = false,
|
||||
timeoutMs = 8_000,
|
||||
extractActivityInfo: extractActivityInfoImpl = extractActivityInfo,
|
||||
triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender,
|
||||
now = () => Date.now(),
|
||||
}: EnsureActivityInfoReadyOptions = {},
|
||||
): Promise<ActivityInfo> {
|
||||
if (triggerRender) {
|
||||
await triggerActivityRoleRenderImpl(page)
|
||||
}
|
||||
|
||||
const startedAt = now()
|
||||
let lastInfo = await extractActivityInfoImpl(page)
|
||||
|
||||
while (now() - startedAt < timeoutMs) {
|
||||
if (lastInfo?.role?.ready) {
|
||||
return lastInfo
|
||||
}
|
||||
|
||||
await page.waitForTimeout(400)
|
||||
lastInfo = await extractActivityInfoImpl(page)
|
||||
}
|
||||
|
||||
return lastInfo
|
||||
}
|
||||
|
||||
export async function triggerActivityRoleRender(page: BrowserPageLike): Promise<void> {
|
||||
try {
|
||||
await page.evaluate(() => {
|
||||
const maybeRender = window.renderCdkey
|
||||
|
||||
if (typeof maybeRender === 'function') {
|
||||
maybeRender()
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// ignore render trigger failures; polling will still inspect the page
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractActivityInfo(page: BrowserPageLike): Promise<ActivityInfo> {
|
||||
try {
|
||||
return await page.evaluate(() => {
|
||||
const asString = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
|
||||
const isVisible = (element: Element | null) => {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
||||
}
|
||||
|
||||
const pick = (selector: string) => document.querySelector(selector)
|
||||
const pickBest = (
|
||||
selector: string,
|
||||
predicate: ((element: Element) => boolean) | null = null,
|
||||
): Element | null => {
|
||||
const matches = [...document.querySelectorAll(selector)]
|
||||
const preferred = matches.find((element) => {
|
||||
if (predicate && !predicate(element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return isVisible(element)
|
||||
})
|
||||
|
||||
if (preferred) {
|
||||
return preferred
|
||||
}
|
||||
|
||||
if (predicate) {
|
||||
return matches.find((element) => predicate(element)) || null
|
||||
}
|
||||
|
||||
return matches[0] || null
|
||||
}
|
||||
const textOf = (selector) => asString(pick(selector)?.textContent || '')
|
||||
const inputOf = (selector) => {
|
||||
const element = pickBest(
|
||||
selector,
|
||||
(node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement,
|
||||
)
|
||||
|
||||
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return asString(element.value || '')
|
||||
}
|
||||
|
||||
const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {}
|
||||
const roleSelector = pick('[id^="milo_role_selector_"]')
|
||||
const roleNameText =
|
||||
textOf('#role_name') ||
|
||||
asString(roleSelector instanceof HTMLSelectElement ? roleSelector.selectedOptions?.[0]?.textContent || '' : '')
|
||||
const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement)
|
||||
const popup = pick('#pop2')
|
||||
const cdkeyInput = pickBest(
|
||||
'[id^="milo_cdkeyInfo_"]',
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden',
|
||||
)
|
||||
const verifyInput = pickBest(
|
||||
'[id^="milo_verifyInput_"]',
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden',
|
||||
)
|
||||
const submitButton = pickBest(
|
||||
'#milo_cdkey_submit, [id^="milo_cdkey_submit"]',
|
||||
(node) => node instanceof HTMLElement && !node.hasAttribute('disabled'),
|
||||
)
|
||||
|
||||
return {
|
||||
nickname: textOf('#login_nickname_span'),
|
||||
role: {
|
||||
ready: Boolean(window.isRole || roleNameText),
|
||||
roleName: roleNameText,
|
||||
roleId: asString(roleData.sRoleId),
|
||||
area: asString(roleData.sArea || '36'),
|
||||
partition: asString(roleData.sPartition),
|
||||
platId: asString(roleData.sPlatId),
|
||||
md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr),
|
||||
checkparam: asString(roleData.sCheckparam),
|
||||
},
|
||||
form: {
|
||||
cdkeyInputId: cdkeyInput?.id || '',
|
||||
cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'),
|
||||
verifyInputId: verifyInput?.id || '',
|
||||
verifyValue: inputOf('[id^="milo_verifyInput_"]'),
|
||||
verifyImgId: verifyImg?.id || '',
|
||||
submitId: submitButton?.id || '',
|
||||
},
|
||||
verify: {
|
||||
visible: isVisible(verifyImg),
|
||||
src: asString(verifyImg?.getAttribute('src')),
|
||||
naturalWidth: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalWidth || 0) : 0,
|
||||
naturalHeight: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalHeight || 0) : 0,
|
||||
},
|
||||
popup: {
|
||||
visible: isVisible(popup),
|
||||
text: textOf('#PopText'),
|
||||
detail: textOf('#PopText2'),
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return createEmptyActivityInfo()
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import process from 'node:process'
|
||||
|
||||
export type LoginType = 'qq' | 'wx'
|
||||
|
||||
export const DEFAULT_LOGIN_TYPE: LoginType = 'qq'
|
||||
|
||||
type BrowserConfigInput = {
|
||||
headless?: boolean
|
||||
devtools?: boolean
|
||||
keepAlive?: boolean
|
||||
prewarm?: boolean
|
||||
slowMoMs?: number
|
||||
}
|
||||
|
||||
type ResolveBrowserLaunchOptionsInput = {
|
||||
browserConfig?: BrowserConfigInput
|
||||
chromePath?: string
|
||||
nodeEnv?: string
|
||||
ci?: string
|
||||
}
|
||||
|
||||
export type BrowserLaunchOptions = {
|
||||
headless: boolean
|
||||
devtools: boolean
|
||||
usesBundledChromium: boolean
|
||||
keepAlive: boolean
|
||||
prewarm: boolean
|
||||
slowMoMs: number
|
||||
}
|
||||
|
||||
export type ViewportSize = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function resolveBrowserLaunchOptions(options: ResolveBrowserLaunchOptionsInput = {}): BrowserLaunchOptions {
|
||||
const browserConfig = options.browserConfig || {}
|
||||
const chromePath = options.chromePath || ''
|
||||
const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV
|
||||
const ci = options.ci ?? process.env.CI
|
||||
const explicitHeadless = browserConfig.headless
|
||||
const explicitDevtools = browserConfig.devtools
|
||||
const explicitKeepAlive = browserConfig.keepAlive
|
||||
const explicitPrewarm = browserConfig.prewarm
|
||||
const explicitSlowMo = browserConfig.slowMoMs
|
||||
const isProductionLike = nodeEnv === 'production' || ci === '1'
|
||||
|
||||
const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike
|
||||
|
||||
const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless
|
||||
|
||||
const slowMoMs = Number.isFinite(explicitSlowMo)
|
||||
? Math.max(0, Math.min(explicitSlowMo, 2_000))
|
||||
: headless
|
||||
? 0
|
||||
: 150
|
||||
|
||||
const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike
|
||||
const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive
|
||||
|
||||
return {
|
||||
headless,
|
||||
devtools,
|
||||
usesBundledChromium: !chromePath,
|
||||
keepAlive,
|
||||
prewarm,
|
||||
slowMoMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildChromiumLaunchOptions(
|
||||
launchOptions: BrowserLaunchOptions,
|
||||
{ chromePath = '' }: { chromePath?: string } = {},
|
||||
) {
|
||||
const options = {
|
||||
headless: launchOptions.headless,
|
||||
devtools: launchOptions.devtools,
|
||||
slowMo: launchOptions.slowMoMs,
|
||||
}
|
||||
|
||||
if (chromePath) {
|
||||
return {
|
||||
...options,
|
||||
executablePath: chromePath,
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export function resolveDefaultViewport(rawValue?: unknown): ViewportSize {
|
||||
const fromVncResolution = parseViewportSpec(rawValue)
|
||||
if (fromVncResolution) {
|
||||
return fromVncResolution
|
||||
}
|
||||
|
||||
return { width: 1600, height: 900 }
|
||||
}
|
||||
|
||||
export function parseViewportSpec(rawValue: unknown): ViewportSize | null {
|
||||
const text = String(rawValue || '').trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const width = Number(match[1])
|
||||
const height = Number(match[2])
|
||||
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
export function normalizeLoginType(value: unknown, fallback: LoginType = DEFAULT_LOGIN_TYPE): LoginType {
|
||||
const normalized = String(value || fallback).trim().toLowerCase()
|
||||
if (['qq', 'qc'].includes(normalized)) {
|
||||
return 'qq'
|
||||
}
|
||||
if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) {
|
||||
return 'wx'
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function getLoginTypeLabel(loginType: LoginType | string): string {
|
||||
return loginType === 'wx' ? '微信' : 'QQ'
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
const TENCENT_COOKIE_URLS = [
|
||||
'https://df.qq.com',
|
||||
'https://ssl.captcha.qq.com',
|
||||
'https://graph.qq.com',
|
||||
'https://milo.qq.com',
|
||||
]
|
||||
|
||||
type BrowserCookie = {
|
||||
name?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
type BrowserContextLike = {
|
||||
cookies: (urls?: string[]) => Promise<BrowserCookie[]>
|
||||
}
|
||||
|
||||
export type TencentCookieMap = Record<string, string>
|
||||
|
||||
export function normalizeTencentCookieMap(cookies: BrowserCookie[] = []): TencentCookieMap {
|
||||
const cookieMap: TencentCookieMap = {}
|
||||
|
||||
for (const cookie of cookies) {
|
||||
if (!cookie?.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
cookieMap[String(cookie.name)] = String(cookie.value || '')
|
||||
}
|
||||
|
||||
if (!cookieMap.acctype) {
|
||||
cookieMap.acctype = 'qc'
|
||||
}
|
||||
|
||||
if (!cookieMap.appid) {
|
||||
cookieMap.appid = '101491592'
|
||||
}
|
||||
|
||||
if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) {
|
||||
cookieMap.iegams_milo_proxylogin_qc =
|
||||
`${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}`
|
||||
}
|
||||
|
||||
return cookieMap
|
||||
}
|
||||
|
||||
export async function loadCookieMapFromContext(browserContext: BrowserContextLike): Promise<TencentCookieMap> {
|
||||
const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS)
|
||||
return normalizeTencentCookieMap(cookies)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { logDebug } from '../../utils/logger.js'
|
||||
|
||||
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
|
||||
type FrameLike = unknown
|
||||
|
||||
type PageWithFrames = {
|
||||
frames: () => FrameLike[]
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
}
|
||||
|
||||
type QrDownloadResponse = {
|
||||
ok: () => boolean
|
||||
status: () => number
|
||||
body: () => Promise<Buffer>
|
||||
}
|
||||
|
||||
type PageWithRequestContext = {
|
||||
context: () => {
|
||||
request: {
|
||||
get: (url: string, options?: {
|
||||
failOnStatusCode?: boolean
|
||||
headers?: Record<string, string>
|
||||
}) => Promise<QrDownloadResponse>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function logBrowserSessionDebug(scope: string, detail?: unknown): void {
|
||||
if (!SESSION_DEBUG_ENABLED) {
|
||||
return
|
||||
}
|
||||
|
||||
logDebug('[browser/session]', scope, detail)
|
||||
}
|
||||
|
||||
export async function waitForFrame<TFrame extends FrameLike>(
|
||||
page: PageWithFrames,
|
||||
predicate: (frame: TFrame) => boolean,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<TFrame> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const matched = page.frames().find((frame): frame is TFrame => {
|
||||
try {
|
||||
return predicate(frame as TFrame)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
throw new Error('未找到登录二维码 frame')
|
||||
}
|
||||
|
||||
export async function downloadRemoteQrImage(
|
||||
page: PageWithRequestContext,
|
||||
qrUrl: string,
|
||||
qrImagePath: string,
|
||||
referer = '',
|
||||
loginType = 'unknown',
|
||||
): Promise<Buffer | false | null> {
|
||||
try {
|
||||
const response = await page.context().request.get(qrUrl, {
|
||||
failOnStatusCode: false,
|
||||
headers: {
|
||||
referer,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok()) {
|
||||
logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, {
|
||||
qrUrl,
|
||||
status: response.status(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const body = await response.body()
|
||||
|
||||
if (!body?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return body
|
||||
} catch (error) {
|
||||
logBrowserSessionDebug(`${loginType}.capture.downloadError`, {
|
||||
qrUrl,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isRetryableQrCaptureError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error || '')
|
||||
|
||||
return (
|
||||
message.includes('Frame was detached') ||
|
||||
message.includes('Execution context was destroyed') ||
|
||||
message.includes('Target page, context or browser has been closed') ||
|
||||
message.includes('waiting for') ||
|
||||
message.includes('Timeout')
|
||||
)
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js'
|
||||
import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js'
|
||||
import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js'
|
||||
|
||||
type LoginType = 'qq' | 'wx' | string
|
||||
|
||||
type LocatorLike = {
|
||||
count: () => Promise<number>
|
||||
first: () => LocatorLike
|
||||
click: (options?: { timeout?: number }) => Promise<void>
|
||||
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
|
||||
screenshot: (options: { path: string }) => Promise<void>
|
||||
}
|
||||
|
||||
type BrowserPageLike = {
|
||||
frames: () => any[]
|
||||
locator: (selector: string) => LocatorLike
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
evaluate: <T = any>(callback: any) => Promise<T>
|
||||
reload: (options?: { waitUntil?: string }) => Promise<unknown>
|
||||
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
|
||||
context: () => any
|
||||
}
|
||||
|
||||
type SessionForQrCapture = {
|
||||
page: BrowserPageLike
|
||||
loginType: LoginType
|
||||
sessionDir: string
|
||||
qrImagePath?: string
|
||||
qrImageBase64?: string
|
||||
qrUpdatedAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
type CaptureSessionQrOptions = {
|
||||
ensureLoginReady?: boolean
|
||||
ensureLoginTab?: typeof ensureLoginTab
|
||||
captureQrImageWithRetry?: typeof captureQrImageWithRetry
|
||||
readFile?: (path: string, encoding: BufferEncoding) => Promise<string>
|
||||
now?: () => string
|
||||
}
|
||||
|
||||
type CaptureQrImageWithRetryOptions = {
|
||||
captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
|
||||
captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
|
||||
ensureLoginTab?: typeof ensureLoginTab
|
||||
isRetryableQrCaptureError?: (error: unknown) => boolean
|
||||
logBrowserSessionDebug?: (scope: string, detail?: unknown) => void
|
||||
}
|
||||
|
||||
type EnsureLoginTabOptions = {
|
||||
ensureQqLoginReady?: (page: BrowserPageLike) => Promise<unknown>
|
||||
ensureWxLoginReady?: (page: BrowserPageLike) => Promise<unknown>
|
||||
}
|
||||
|
||||
type ExtractQrStateOptions = {
|
||||
extractQqQrState?: (page: BrowserPageLike) => Promise<unknown>
|
||||
extractWxQrState?: (page: BrowserPageLike) => Promise<unknown>
|
||||
}
|
||||
|
||||
export async function captureSessionQr(
|
||||
session: SessionForQrCapture,
|
||||
{
|
||||
ensureLoginReady = true,
|
||||
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
|
||||
captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry,
|
||||
readFile = fs.readFile,
|
||||
now = () => new Date().toISOString(),
|
||||
}: CaptureSessionQrOptions = {},
|
||||
): Promise<void> {
|
||||
if (ensureLoginReady) {
|
||||
await ensureLoginTabImpl(session.page, session.loginType)
|
||||
}
|
||||
|
||||
const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`)
|
||||
await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, {
|
||||
ensureLoginTab: ensureLoginTabImpl,
|
||||
})
|
||||
|
||||
session.qrImagePath = qrImagePath
|
||||
session.qrImageBase64 = await readFile(qrImagePath, 'base64')
|
||||
session.qrUpdatedAt = now()
|
||||
session.updatedAt = session.qrUpdatedAt
|
||||
}
|
||||
|
||||
export async function captureQrImageWithRetry(
|
||||
page: BrowserPageLike,
|
||||
loginType: LoginType,
|
||||
qrImagePath: string,
|
||||
{
|
||||
captureQqQrImage: captureQqQrImageImpl = captureQqQrImage,
|
||||
captureWxQrImage: captureWxQrImageImpl = captureWxQrImage,
|
||||
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
|
||||
isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError,
|
||||
logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug,
|
||||
}: CaptureQrImageWithRetryOptions = {},
|
||||
): Promise<void> {
|
||||
const maxAttempts = loginType === 'qq' ? 4 : 3
|
||||
let lastError: unknown = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
logBrowserSessionDebugImpl('captureQrImageWithRetry.start', {
|
||||
loginType,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (loginType === 'qq') {
|
||||
await captureQqQrImageImpl(page, qrImagePath)
|
||||
} else {
|
||||
await captureWxQrImageImpl(page, qrImagePath)
|
||||
}
|
||||
|
||||
logBrowserSessionDebugImpl('captureQrImageWithRetry.success', {
|
||||
loginType,
|
||||
attempt,
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logBrowserSessionDebugImpl('captureQrImageWithRetry.error', {
|
||||
loginType,
|
||||
attempt,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
await ensureLoginTabImpl(page, loginType)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
throw new Error('二维码截图失败')
|
||||
}
|
||||
|
||||
export async function ensureLoginTab(
|
||||
page: BrowserPageLike,
|
||||
loginType: LoginType,
|
||||
{
|
||||
ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady,
|
||||
ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady,
|
||||
}: EnsureLoginTabOptions = {},
|
||||
): Promise<void> {
|
||||
const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab'
|
||||
const loginButton = page.locator('#unlogin')
|
||||
let interacted = false
|
||||
|
||||
if (await loginButton.count()) {
|
||||
try {
|
||||
await loginButton.first().click({ timeout: 2_000 })
|
||||
interacted = true
|
||||
} catch {
|
||||
// ignore when the login layer is already open
|
||||
}
|
||||
}
|
||||
|
||||
const tab = page.locator(tabSelector).first()
|
||||
|
||||
if (await tab.count()) {
|
||||
try {
|
||||
await tab.click({ timeout: 2_000 })
|
||||
interacted = true
|
||||
} catch {
|
||||
// ignore tab click failures
|
||||
}
|
||||
}
|
||||
|
||||
if (interacted) {
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
if (loginType === 'qq') {
|
||||
await ensureQqLoginReadyImpl(page)
|
||||
return
|
||||
}
|
||||
|
||||
if (loginType === 'wx') {
|
||||
await ensureWxLoginReadyImpl(page)
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractHostState(page: BrowserPageLike) {
|
||||
return page.evaluate(() => {
|
||||
const isVisible = (element: Element | null) => {
|
||||
if (!element) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
|
||||
}
|
||||
|
||||
const unlogin = document.querySelector('#unlogin')
|
||||
const logined = document.querySelector('#logined')
|
||||
|
||||
return {
|
||||
unloginVisible: isVisible(unlogin),
|
||||
loginedVisible: isVisible(logined),
|
||||
loginText: String(logined?.textContent || '').trim(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function reloadActivityPageForPresentation(page: BrowserPageLike, activityUrl: string): Promise<void> {
|
||||
try {
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
} catch {
|
||||
await page.goto(activityUrl, { waitUntil: 'domcontentloaded' })
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2_500)
|
||||
}
|
||||
|
||||
export async function extractQrState(
|
||||
page: BrowserPageLike,
|
||||
loginType: LoginType,
|
||||
{
|
||||
extractQqQrState: extractQqQrStateImpl = extractQqQrState,
|
||||
extractWxQrState: extractWxQrStateImpl = extractWxQrState,
|
||||
}: ExtractQrStateOptions = {},
|
||||
): Promise<unknown> {
|
||||
if (loginType === 'qq') {
|
||||
return extractQqQrStateImpl(page)
|
||||
}
|
||||
|
||||
return extractWxQrStateImpl(page)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
export function buildSessionPayload(
|
||||
session: TencentBrowserSessionPayloadSource,
|
||||
{
|
||||
activityUrl = '',
|
||||
includeQrImage = true,
|
||||
sessionDebugEnabled = false,
|
||||
browserDebug = null,
|
||||
}: BuildSessionPayloadOptions = {},
|
||||
): any {
|
||||
const activityInfo = session.lastState?.activityInfo || null
|
||||
const payload: Record<string, any> = {
|
||||
sessionId: session.sessionId,
|
||||
loginType: session.loginType,
|
||||
activityUrl,
|
||||
status: session.status,
|
||||
notice: session.notice,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
expiresAt: new Date(session.expiresAt).toISOString(),
|
||||
qrUpdatedAt: session.qrUpdatedAt,
|
||||
credentialReady: Boolean(session.lastState?.credentialReady),
|
||||
activityInfo,
|
||||
review: buildReviewPayload(session.lastReview),
|
||||
redeem: buildRedeemPayload(session.lastRedeem),
|
||||
artifacts: buildArtifactsPayload(session),
|
||||
}
|
||||
|
||||
if (includeQrImage) {
|
||||
payload.qrImageBase64 = session.qrImageBase64
|
||||
}
|
||||
|
||||
if (!sessionDebugEnabled) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [],
|
||||
state: session.lastState,
|
||||
browserDebug,
|
||||
qrImagePath: session.qrImagePath,
|
||||
review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }),
|
||||
redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }),
|
||||
artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }),
|
||||
}
|
||||
}
|
||||
|
||||
type BuildSessionPayloadOptions = {
|
||||
activityUrl?: string
|
||||
includeQrImage?: boolean
|
||||
sessionDebugEnabled?: boolean
|
||||
browserDebug?: unknown
|
||||
}
|
||||
|
||||
type InternalPathOptions = {
|
||||
includeInternalPaths?: boolean
|
||||
}
|
||||
|
||||
type TencentBrowserSessionPayloadSource = {
|
||||
sessionId: string
|
||||
loginType?: string
|
||||
status?: string
|
||||
notice?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
expiresAt: string | number | Date
|
||||
qrUpdatedAt?: string
|
||||
qrImageBase64?: string
|
||||
qrImagePath?: string
|
||||
sessionDir?: string
|
||||
lastState?: {
|
||||
credentialReady?: boolean
|
||||
cookieKeys?: string[]
|
||||
activityInfo?: any
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
lastReview?: ReviewPayloadSource | null
|
||||
lastRedeem?: RedeemPayloadSource | null
|
||||
}
|
||||
|
||||
type ReviewPayloadSource = {
|
||||
capturedAt?: string
|
||||
roleId?: string
|
||||
roleName?: string
|
||||
screenshotPath?: string
|
||||
signature?: string
|
||||
}
|
||||
|
||||
type RedeemAttemptPayloadSource = {
|
||||
attempt?: number
|
||||
redeem?: unknown
|
||||
}
|
||||
|
||||
type RedeemPayloadSource = {
|
||||
code?: string
|
||||
area?: string
|
||||
proofMode?: string
|
||||
attempts?: RedeemAttemptPayloadSource[]
|
||||
final?: RedeemAttemptPayloadSource | null
|
||||
finishedAt?: string
|
||||
screenshotPath?: string
|
||||
htmlPath?: string
|
||||
resultPath?: string
|
||||
}
|
||||
|
||||
export function buildReviewPayload(review: ReviewPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) {
|
||||
if (!review) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = {
|
||||
capturedAt: review.capturedAt,
|
||||
roleId: review.roleId || '',
|
||||
roleName: review.roleName || '',
|
||||
}
|
||||
|
||||
if (!includeInternalPaths) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
screenshotPath: review.screenshotPath || '',
|
||||
signature: review.signature || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRedeemPayload(redeem: RedeemPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) {
|
||||
if (!redeem) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code: redeem.code,
|
||||
area: redeem.area,
|
||||
proofMode: redeem.proofMode || 'full',
|
||||
attempts: Array.isArray(redeem.attempts)
|
||||
? redeem.attempts.map((attempt) => ({
|
||||
attempt: attempt.attempt,
|
||||
redeem: attempt.redeem || null,
|
||||
}))
|
||||
: [],
|
||||
final: redeem.final
|
||||
? {
|
||||
attempt: redeem.final.attempt,
|
||||
redeem: redeem.final.redeem || null,
|
||||
}
|
||||
: null,
|
||||
finishedAt: redeem.finishedAt,
|
||||
}
|
||||
|
||||
if (!includeInternalPaths) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
screenshotPath: redeem.screenshotPath || '',
|
||||
htmlPath: redeem.htmlPath || '',
|
||||
resultPath: redeem.resultPath || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildArtifactsPayload(
|
||||
session: Pick<
|
||||
TencentBrowserSessionPayloadSource,
|
||||
'sessionDir' | 'qrImagePath' | 'qrImageBase64' | 'lastReview' | 'lastRedeem'
|
||||
>,
|
||||
{ includeInternalPaths = false }: InternalPathOptions = {},
|
||||
) {
|
||||
const payload = {
|
||||
hasQrImage: Boolean(session.qrImageBase64),
|
||||
hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath),
|
||||
hasScreenshot: Boolean(session.lastRedeem?.screenshotPath),
|
||||
}
|
||||
|
||||
if (!includeInternalPaths) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
sessionDir: session.sessionDir,
|
||||
qrImagePath: session.qrImagePath,
|
||||
screenshotPath: session.lastRedeem?.screenshotPath || '',
|
||||
htmlPath: session.lastRedeem?.htmlPath || '',
|
||||
resultPath: session.lastRedeem?.resultPath || '',
|
||||
reviewScreenshotPath: session.lastReview?.screenshotPath || '',
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {
|
||||
BAIDU_BEIJING_TIME_URL,
|
||||
BEIJING_TIME_PROOF_CLIP,
|
||||
BEIJING_TIME_PROOF_VIEWPORT,
|
||||
} from './session-proof-constants.js'
|
||||
|
||||
type BrowserPageLike = {
|
||||
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
|
||||
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
evaluate: (callback: () => unknown) => Promise<unknown>
|
||||
screenshot: (options: {
|
||||
path: string
|
||||
clip: { x: number; y: number; width: number; height: number }
|
||||
}) => Promise<unknown>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
type BrowserContextLike = {
|
||||
newPage: () => Promise<BrowserPageLike>
|
||||
}
|
||||
|
||||
export type BeijingTimeProof = {
|
||||
screenshotPath: string
|
||||
pageUrl: string
|
||||
}
|
||||
|
||||
export async function captureBeijingTimeProof(
|
||||
browserContext: BrowserContextLike,
|
||||
{ screenshotPath }: { screenshotPath: string },
|
||||
): Promise<BeijingTimeProof> {
|
||||
const proofPage = await browserContext.newPage()
|
||||
|
||||
try {
|
||||
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
|
||||
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
|
||||
await proofPage.waitForTimeout(3_000)
|
||||
await proofPage.evaluate(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
await proofPage.screenshot({
|
||||
path: screenshotPath,
|
||||
clip: BEIJING_TIME_PROOF_CLIP,
|
||||
})
|
||||
|
||||
return {
|
||||
screenshotPath,
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
}
|
||||
} finally {
|
||||
await proofPage.close().catch(() => null)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1'
|
||||
export type RedeemProofMode = 'full' | 'basic' | 'off'
|
||||
|
||||
export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
|
||||
export const BEIJING_TIME_PROOF_CLIP = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: BEIJING_TIME_PROOF_VIEWPORT.width,
|
||||
height: 620,
|
||||
}
|
||||
export const DEFAULT_REDEEM_PROOF_MODE: RedeemProofMode = 'full'
|
||||
@@ -1,180 +0,0 @@
|
||||
export type ProofHtmlInput = {
|
||||
redeemImageBase64: string
|
||||
timeImageBase64: string
|
||||
redeemMessage?: string
|
||||
timePageUrl: string
|
||||
}
|
||||
|
||||
export function buildProofHtml({
|
||||
redeemImageBase64,
|
||||
timeImageBase64,
|
||||
redeemMessage,
|
||||
timePageUrl,
|
||||
}: ProofHtmlInput): string {
|
||||
const capturedAt = new Date().toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
})
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>兑换与北京时间凭证</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
|
||||
color: #142033;
|
||||
}
|
||||
.sheet {
|
||||
width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 24px 28px;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
padding: 0 6px 18px;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: #1c78d0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 44px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.summary {
|
||||
margin: 14px 0 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
color: #52627a;
|
||||
}
|
||||
.meta {
|
||||
min-width: 320px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
|
||||
}
|
||||
.meta strong,
|
||||
.meta span {
|
||||
display: block;
|
||||
}
|
||||
.meta strong {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #60748d;
|
||||
}
|
||||
.meta span {
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
color: #142033;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
.card p {
|
||||
margin: 8px 0 0;
|
||||
color: #5a6b83;
|
||||
line-height: 1.65;
|
||||
font-size: 16px;
|
||||
}
|
||||
.preview {
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||
background: #dfe7f3;
|
||||
}
|
||||
.preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.preview--time {
|
||||
max-height: 500px;
|
||||
}
|
||||
.preview--time img {
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
.url {
|
||||
margin-top: 10px;
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||
font-size: 14px;
|
||||
color: #58708f;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="sheet">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Tencent Redeem Proof</p>
|
||||
<h1>兑换截图与北京时间截图</h1>
|
||||
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<strong>Captured At</strong>
|
||||
<span>${escapeHtml(capturedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>兑换结果截图</h2>
|
||||
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
|
||||
<div class="preview">
|
||||
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>百度检索“北京时间”截图</h2>
|
||||
<p>单独标签页打开百度搜索结果并截图。</p>
|
||||
<div class="url">${escapeHtml(timePageUrl)}</div>
|
||||
<div class="preview preview--time">
|
||||
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export function escapeHtml(value: unknown): string {
|
||||
return String(value || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
|
||||
import { DEFAULT_REDEEM_PROOF_MODE, type RedeemProofMode } from './session-proof-constants.js'
|
||||
|
||||
type RedeemResultForProof = {
|
||||
classification?: {
|
||||
success?: boolean
|
||||
}
|
||||
redeem?: {
|
||||
iRet?: number | string
|
||||
ret?: number | string
|
||||
}
|
||||
} | null | undefined
|
||||
|
||||
export function resolveRedeemProofMode(rawMode: unknown = runtimeConfig.redeem.proofMode): RedeemProofMode {
|
||||
const normalized = String(rawMode || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (normalized === 'basic' || normalized === 'off') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return DEFAULT_REDEEM_PROOF_MODE
|
||||
}
|
||||
|
||||
export function shouldCaptureBeijingTimeProof(finalResult: RedeemResultForProof): boolean {
|
||||
if (finalResult?.classification?.success === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (finalResult?.classification?.success === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret)
|
||||
return Number.isFinite(retCode) && retCode === 0
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export type RedeemArtifactPaths = {
|
||||
redeemPageScreenshotPath: string
|
||||
beijingTimeScreenshotPath: string
|
||||
screenshotPath: string
|
||||
htmlPath: string
|
||||
resultPath: string
|
||||
}
|
||||
|
||||
export function buildArtifactPaths(outputDir: string): RedeemArtifactPaths {
|
||||
return {
|
||||
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
|
||||
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
|
||||
screenshotPath: path.join(outputDir, 'redeem-result.png'),
|
||||
htmlPath: path.join(outputDir, 'page.html'),
|
||||
resultPath: path.join(outputDir, 'result.json'),
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { buildProofHtml } from './session-proof-html.js'
|
||||
|
||||
type BrowserPageLike = {
|
||||
evaluate: <T = unknown>(callback: any, arg?: unknown) => Promise<T>
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
|
||||
setContent: (html: string, options?: { waitUntil?: string }) => Promise<unknown>
|
||||
screenshot: (options: { path: string; fullPage?: boolean }) => Promise<unknown>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
type BrowserContextLike = {
|
||||
newPage: () => Promise<BrowserPageLike>
|
||||
}
|
||||
|
||||
type ComposeProofScreenshotOptions = {
|
||||
outputPath: string
|
||||
redeemImagePath: string
|
||||
timeImagePath: string
|
||||
redeemMessage?: string
|
||||
timePageUrl: string
|
||||
}
|
||||
|
||||
export async function showResultDialog(page: BrowserPageLike, message: string): Promise<void> {
|
||||
await page.evaluate((text) => {
|
||||
document.querySelectorAll('iframe').forEach((element) => element.remove())
|
||||
document.querySelectorAll('.pop').forEach((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.display = 'none'
|
||||
}
|
||||
})
|
||||
|
||||
let card = document.getElementById('codex-redeem-result')
|
||||
|
||||
if (!card) {
|
||||
card = document.createElement('div')
|
||||
card.id = 'codex-redeem-result'
|
||||
card.innerHTML = `
|
||||
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
|
||||
<div class="codex-redeem-result__title">兑换结果</div>
|
||||
<div class="codex-redeem-result__message"></div>
|
||||
`
|
||||
document.body.appendChild(card)
|
||||
}
|
||||
|
||||
const messageNode = card.querySelector('.codex-redeem-result__message')
|
||||
|
||||
if (messageNode) {
|
||||
messageNode.textContent = text
|
||||
}
|
||||
|
||||
Object.assign(card.style, {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '99999',
|
||||
width: 'min(560px, calc(100vw - 80px))',
|
||||
padding: '32px 36px',
|
||||
borderRadius: '24px',
|
||||
background: 'rgba(10, 18, 28, 0.92)',
|
||||
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
|
||||
border: '1px solid rgba(109, 241, 202, 0.25)',
|
||||
color: '#f4fbff',
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
})
|
||||
|
||||
const styleId = 'codex-redeem-result-style'
|
||||
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
#codex-redeem-result .codex-redeem-result__eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6df1ca;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
#codex-redeem-result .codex-redeem-result__title {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#codex-redeem-result .codex-redeem-result__message {
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
color: #ffffff;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0)
|
||||
}, message)
|
||||
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
export async function composeProofScreenshot(
|
||||
browserContext: BrowserContextLike,
|
||||
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }: ComposeProofScreenshotOptions,
|
||||
): Promise<void> {
|
||||
const [redeemImageBase64, timeImageBase64] = await Promise.all([
|
||||
fs.readFile(redeemImagePath, 'base64'),
|
||||
fs.readFile(timeImagePath, 'base64'),
|
||||
])
|
||||
|
||||
const composePage = await browserContext.newPage()
|
||||
|
||||
try {
|
||||
await composePage.setViewportSize({ width: 1520, height: 1900 })
|
||||
await composePage.setContent(
|
||||
buildProofHtml({
|
||||
redeemImageBase64,
|
||||
timeImageBase64,
|
||||
redeemMessage,
|
||||
timePageUrl,
|
||||
}),
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
)
|
||||
await composePage.screenshot({ path: outputPath, fullPage: true })
|
||||
} finally {
|
||||
await composePage.close().catch(() => null)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { BAIDU_BEIJING_TIME_URL, type RedeemProofMode } from './session-proof-constants.js'
|
||||
|
||||
type RedeemResultFilePayload = {
|
||||
proofMode: RedeemProofMode
|
||||
sessionId?: string
|
||||
activityUrl: string
|
||||
code: string
|
||||
finalResult?: {
|
||||
role?: {
|
||||
area?: string
|
||||
}
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
attempts?: unknown[]
|
||||
screenshotPath: string
|
||||
htmlPath?: string
|
||||
redeemPageScreenshotPath?: string
|
||||
beijingTimeScreenshotPath?: string
|
||||
beijingTimePageUrl?: string
|
||||
beijingTimeError?: string
|
||||
}
|
||||
|
||||
export async function writeRedeemResultFile(
|
||||
resultPath: string,
|
||||
{
|
||||
proofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
htmlPath = '',
|
||||
redeemPageScreenshotPath = '',
|
||||
beijingTimeScreenshotPath = '',
|
||||
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
|
||||
beijingTimeError = '',
|
||||
}: RedeemResultFilePayload,
|
||||
): Promise<void> {
|
||||
await fs.writeFile(
|
||||
resultPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
proofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
area: finalResult?.role?.area || '',
|
||||
final: finalResult,
|
||||
attempts,
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath,
|
||||
beijingTimePageUrl,
|
||||
beijingTimeError,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js'
|
||||
import { buildProofHtml } from './session-proof-html.js'
|
||||
import { buildArtifactPaths } from './session-proof-paths.js'
|
||||
|
||||
test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => {
|
||||
assert.equal(resolveRedeemProofMode('basic'), 'basic')
|
||||
assert.equal(resolveRedeemProofMode(' OFF '), 'off')
|
||||
assert.equal(resolveRedeemProofMode('full'), 'full')
|
||||
assert.equal(resolveRedeemProofMode('unexpected'), 'full')
|
||||
assert.equal(resolveRedeemProofMode(''), 'full')
|
||||
})
|
||||
|
||||
test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => {
|
||||
assert.equal(
|
||||
shouldCaptureBeijingTimeProof({
|
||||
classification: {
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldCaptureBeijingTimeProof({
|
||||
classification: {
|
||||
success: false,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldCaptureBeijingTimeProof({
|
||||
redeem: {
|
||||
iRet: 0,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldCaptureBeijingTimeProof({
|
||||
redeem: {
|
||||
iRet: -183,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(shouldCaptureBeijingTimeProof(null), false)
|
||||
})
|
||||
|
||||
test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => {
|
||||
const artifactPaths = buildArtifactPaths('/tmp/redeem-proof')
|
||||
|
||||
assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png'))
|
||||
assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png'))
|
||||
assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png'))
|
||||
assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html'))
|
||||
assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json'))
|
||||
})
|
||||
|
||||
test('buildProofHtml escapes injected text while keeping proof sections stable', () => {
|
||||
const html = buildProofHtml({
|
||||
redeemImageBase64: 'redeem-base64',
|
||||
timeImageBase64: 'time-base64',
|
||||
redeemMessage: '<b>兑换成功 & 已到账</b>',
|
||||
timePageUrl: 'https://example.com/?q=<北京时间>',
|
||||
})
|
||||
|
||||
assert.match(html, /Tencent Redeem Proof/)
|
||||
assert.match(html, /兑换截图与北京时间截图/)
|
||||
assert.match(html, /<b>兑换成功 & 已到账<\/b>/)
|
||||
assert.match(html, /https:\/\/example\.com\/\?q=<北京时间>/)
|
||||
assert.match(html, /data:image\/png;base64,redeem-base64/)
|
||||
assert.match(html, /data:image\/png;base64,time-base64/)
|
||||
})
|
||||
@@ -1,205 +0,0 @@
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import {
|
||||
BAIDU_BEIJING_TIME_URL,
|
||||
DEFAULT_REDEEM_PROOF_MODE,
|
||||
type RedeemProofMode,
|
||||
} from './session-proof-constants.js'
|
||||
import { captureBeijingTimeProof } from './session-proof-beijing-time.js'
|
||||
import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js'
|
||||
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js'
|
||||
import { buildArtifactPaths } from './session-proof-paths.js'
|
||||
import { writeRedeemResultFile } from './session-proof-result-writer.js'
|
||||
|
||||
type BrowserPageLike = {
|
||||
evaluate: <T = unknown>(callback: any, arg?: unknown) => Promise<T>
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
setViewportSize: (viewport: { width: number; height: number }) => Promise<void>
|
||||
setContent: (html: string, options?: { waitUntil?: string }) => Promise<unknown>
|
||||
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
|
||||
screenshot: (options: {
|
||||
path: string
|
||||
fullPage?: boolean
|
||||
clip?: { x: number; y: number; width: number; height: number }
|
||||
}) => Promise<unknown>
|
||||
content: () => Promise<string>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
type BrowserContextLike = {
|
||||
newPage: () => Promise<BrowserPageLike>
|
||||
}
|
||||
|
||||
type RedeemFinalResult = {
|
||||
role?: {
|
||||
area?: string
|
||||
}
|
||||
classification?: {
|
||||
success?: boolean
|
||||
}
|
||||
redeem?: {
|
||||
iRet?: number | string
|
||||
ret?: number | string
|
||||
sMsg?: string
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type RedeemArtifactsInput = {
|
||||
browserContext: BrowserContextLike
|
||||
page: BrowserPageLike
|
||||
sessionDir: string
|
||||
sessionId?: string
|
||||
activityUrl: string
|
||||
code: string
|
||||
finalResult: RedeemFinalResult
|
||||
attempts?: unknown[]
|
||||
}
|
||||
|
||||
type WriteRedeemArtifactFilesInput = Omit<RedeemArtifactsInput, 'sessionDir'> & {
|
||||
outputDir: string
|
||||
proofMode?: RedeemProofMode | string
|
||||
}
|
||||
|
||||
type RedeemArtifactsResult = {
|
||||
proofMode: RedeemProofMode
|
||||
screenshotPath: string
|
||||
htmlPath: string
|
||||
resultPath: string
|
||||
}
|
||||
|
||||
export async function saveRedeemArtifacts({
|
||||
browserContext,
|
||||
page,
|
||||
sessionDir,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
}: RedeemArtifactsInput): Promise<RedeemArtifactsResult> {
|
||||
return writeRedeemArtifactFiles({
|
||||
browserContext,
|
||||
page,
|
||||
outputDir: sessionDir,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
proofMode: resolveRedeemProofMode(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeRedeemArtifactFiles({
|
||||
browserContext,
|
||||
page,
|
||||
outputDir,
|
||||
sessionId = '',
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
proofMode = DEFAULT_REDEEM_PROOF_MODE,
|
||||
}: WriteRedeemArtifactFilesInput): Promise<RedeemArtifactsResult> {
|
||||
const effectiveProofMode = resolveRedeemProofMode(proofMode)
|
||||
const {
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
resultPath,
|
||||
} = buildArtifactPaths(outputDir)
|
||||
|
||||
if (effectiveProofMode === 'off') {
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath: '',
|
||||
htmlPath: '',
|
||||
resultPath: '',
|
||||
}
|
||||
}
|
||||
|
||||
await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成')
|
||||
|
||||
if (effectiveProofMode === 'basic') {
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await writeRedeemResultFile(resultPath, {
|
||||
proofMode: effectiveProofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
})
|
||||
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath,
|
||||
htmlPath: '',
|
||||
resultPath,
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true })
|
||||
|
||||
let beijingTimeProof: {
|
||||
screenshotPath: string
|
||||
pageUrl: string
|
||||
error?: string
|
||||
} = {
|
||||
screenshotPath: '',
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
error: '',
|
||||
}
|
||||
|
||||
if (shouldCaptureBeijingTimeProof(finalResult)) {
|
||||
try {
|
||||
beijingTimeProof = await captureBeijingTimeProof(browserContext, {
|
||||
screenshotPath: beijingTimeScreenshotPath,
|
||||
})
|
||||
await composeProofScreenshot(browserContext, {
|
||||
outputPath: screenshotPath,
|
||||
redeemImagePath: redeemPageScreenshotPath,
|
||||
timeImagePath: beijingTimeProof.screenshotPath,
|
||||
redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'),
|
||||
timePageUrl: beijingTimeProof.pageUrl,
|
||||
})
|
||||
} catch (error) {
|
||||
beijingTimeProof = {
|
||||
screenshotPath: '',
|
||||
pageUrl: BAIDU_BEIJING_TIME_URL,
|
||||
error: error instanceof Error ? error.message : '北京时间截图生成失败',
|
||||
}
|
||||
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
|
||||
}
|
||||
} else {
|
||||
await fs.copyFile(redeemPageScreenshotPath, screenshotPath)
|
||||
}
|
||||
|
||||
await fs.writeFile(htmlPath, await page.content(), 'utf8')
|
||||
await writeRedeemResultFile(resultPath, {
|
||||
proofMode: effectiveProofMode,
|
||||
sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
redeemPageScreenshotPath,
|
||||
beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '',
|
||||
beijingTimePageUrl: beijingTimeProof.pageUrl,
|
||||
beijingTimeError: beijingTimeProof.error || '',
|
||||
})
|
||||
|
||||
return {
|
||||
proofMode: effectiveProofMode,
|
||||
screenshotPath,
|
||||
htmlPath,
|
||||
resultPath,
|
||||
}
|
||||
}
|
||||
|
||||
export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof }
|
||||
@@ -1,240 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js'
|
||||
|
||||
const QQ_QR_TARGET_SIZE = 144
|
||||
|
||||
type LocatorLike = {
|
||||
count: () => Promise<number>
|
||||
click: (options?: { timeout?: number }) => Promise<void>
|
||||
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
|
||||
screenshot: (options: { path: string }) => Promise<void>
|
||||
first: () => LocatorLike
|
||||
}
|
||||
|
||||
type FrameLike = {
|
||||
url: () => string
|
||||
locator: (selector: string) => LocatorLike
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
evaluate: <T = any>(callback: any) => Promise<T>
|
||||
}
|
||||
|
||||
type PageLike = {
|
||||
frames: () => FrameLike[]
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
locator: (selector: string) => LocatorLike
|
||||
context: () => {
|
||||
request: {
|
||||
get: (url: string, options?: any) => Promise<{
|
||||
ok: () => boolean
|
||||
status: () => number
|
||||
body: () => Promise<Buffer>
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type QqQrState = {
|
||||
visible: boolean
|
||||
qrVisible?: boolean
|
||||
scanned: boolean
|
||||
expired: boolean
|
||||
message: string
|
||||
frameUrl?: string
|
||||
}
|
||||
|
||||
export async function ensureQqLoginReady(page: PageLike): Promise<void> {
|
||||
try {
|
||||
const frame = await waitForFrame<FrameLike>(
|
||||
page,
|
||||
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
|
||||
10_000,
|
||||
)
|
||||
const switcher = frame.locator('#switcher_qlogin')
|
||||
|
||||
if (!(await switcher.count())) {
|
||||
return
|
||||
}
|
||||
|
||||
await switcher.click({ timeout: 3_000 }).catch(() => null)
|
||||
await frame.waitForTimeout(250)
|
||||
} catch {
|
||||
// ignore qq inner tab switch failures; the screenshot retry path will try again
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureQqQrImage(page: PageLike, qrImagePath: string): Promise<void> {
|
||||
const frame = await waitForFrame<FrameLike>(
|
||||
page,
|
||||
(item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'),
|
||||
10_000,
|
||||
)
|
||||
const qrUrl = await resolveQqQrImageUrl(frame)
|
||||
const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl)
|
||||
|
||||
logBrowserSessionDebug('qq.capture.qrUrl', {
|
||||
qrUrl,
|
||||
effectiveQrUrl,
|
||||
frameUrl: frame.url(),
|
||||
})
|
||||
|
||||
if (effectiveQrUrl) {
|
||||
const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq')
|
||||
logBrowserSessionDebug('qq.capture.downloadResult', {
|
||||
qrUrl: effectiveQrUrl,
|
||||
downloaded: Buffer.isBuffer(body) && body.length > 0,
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
if (Buffer.isBuffer(body) && body.length > 0) {
|
||||
await fs.writeFile(qrImagePath, body)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logBrowserSessionDebug('qq.capture.fallbackScreenshot', {
|
||||
qrImagePath,
|
||||
})
|
||||
|
||||
const qrLocator = await findQqQrLocator(page, frame)
|
||||
|
||||
try {
|
||||
await qrLocator.screenshot({ path: qrImagePath })
|
||||
return
|
||||
} catch {
|
||||
const iframeLocator = page.locator('#milo-qcwx-frame-qc').first()
|
||||
await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await iframeLocator.screenshot({ path: qrImagePath })
|
||||
}
|
||||
}
|
||||
|
||||
export async function findQqQrLocator(page: PageLike, frame: FrameLike | null = null): Promise<LocatorLike> {
|
||||
const effectiveFrame =
|
||||
frame ||
|
||||
await waitForFrame<FrameLike>(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
|
||||
const locator = effectiveFrame.locator('#qrlogin_img')
|
||||
await locator.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
logBrowserSessionDebug('qq.findQrLocator.visibleReady', {
|
||||
frameUrl: effectiveFrame.url(),
|
||||
})
|
||||
return locator
|
||||
}
|
||||
|
||||
export async function extractQqQrState(page: Pick<PageLike, 'frames'>): Promise<QqQrState> {
|
||||
const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'))
|
||||
|
||||
if (!frame) {
|
||||
return {
|
||||
visible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { qrVisible, bodyText } = await frame
|
||||
.evaluate(() => {
|
||||
const isVisible = (element: Element | null) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
const qrImage = document.querySelector('#qrlogin_img')
|
||||
const bodyText = String(document.body?.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
return {
|
||||
qrVisible:
|
||||
qrImage instanceof HTMLImageElement &&
|
||||
isVisible(qrImage) &&
|
||||
Number(qrImage.naturalWidth || 0) > 0 &&
|
||||
Number(qrImage.naturalHeight || 0) > 0,
|
||||
bodyText,
|
||||
}
|
||||
})
|
||||
.catch(() => ({
|
||||
qrVisible: false,
|
||||
bodyText: '',
|
||||
}))
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible,
|
||||
scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText),
|
||||
expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText),
|
||||
message: bodyText.slice(0, 200),
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
visible: true,
|
||||
qrVisible: false,
|
||||
scanned: false,
|
||||
expired: false,
|
||||
message: '',
|
||||
frameUrl: frame.url(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveQqQrImageUrl(frame: FrameLike): Promise<string> {
|
||||
return frame
|
||||
.evaluate(() => {
|
||||
const qrImage = document.querySelector('#qrlogin_img')
|
||||
|
||||
if (!(qrImage instanceof HTMLImageElement)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim()
|
||||
})
|
||||
.then((src) => {
|
||||
if (!src) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, frame.url()).toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.catch(() => '')
|
||||
}
|
||||
|
||||
function upgradeQqQrImageUrl(qrUrl: string): string {
|
||||
if (!qrUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(qrUrl)
|
||||
|
||||
if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) {
|
||||
return qrUrl
|
||||
}
|
||||
|
||||
const currentSize = Number(url.searchParams.get('d') || 0)
|
||||
|
||||
if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) {
|
||||
url.searchParams.set('d', String(QQ_QR_TARGET_SIZE))
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
} catch {
|
||||
return qrUrl
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
classifyTencentRedeemResult,
|
||||
isCaptchaRejectedResult,
|
||||
resolveTencentRedeemMessage,
|
||||
} from './session-redeem.js'
|
||||
|
||||
test('resolveTencentRedeemMessage prefers popup detail over generic title', () => {
|
||||
assert.equal(
|
||||
resolveTencentRedeemMessage({
|
||||
popup: {
|
||||
text: '兑换结果',
|
||||
detail: '兑换码错误,请确认兑换码信息是否准确。',
|
||||
},
|
||||
}),
|
||||
'兑换码错误,请确认兑换码信息是否准确。',
|
||||
)
|
||||
})
|
||||
|
||||
test('classifyTencentRedeemResult marks used codes as replacement retries', () => {
|
||||
const result = classifyTencentRedeemResult({
|
||||
popup: {
|
||||
text: '兑换结果',
|
||||
detail: '兑换码已使用。',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.outcome, 'code_used')
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(result.retryWithReplacementCode, true)
|
||||
})
|
||||
|
||||
test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => {
|
||||
const result = classifyTencentRedeemResult({
|
||||
popup: {
|
||||
text: '兑换结果',
|
||||
detail: '兑换码错误,请确认兑换码信息是否准确。',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.outcome, 'code_invalid')
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(result.retryWithReplacementCode, true)
|
||||
})
|
||||
|
||||
test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => {
|
||||
const result = classifyTencentRedeemResult({
|
||||
iRet: -183,
|
||||
sMsg: '该CDKEY不存在,请您确认后输入!',
|
||||
})
|
||||
|
||||
assert.equal(result.outcome, 'code_invalid')
|
||||
assert.equal(result.success, false)
|
||||
assert.equal(result.retryWithReplacementCode, true)
|
||||
})
|
||||
|
||||
test('classifyTencentRedeemResult recognizes successful gift popup', () => {
|
||||
const result = classifyTencentRedeemResult({
|
||||
popup: {
|
||||
text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。',
|
||||
detail: '',
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.outcome, 'success')
|
||||
assert.equal(result.success, true)
|
||||
})
|
||||
|
||||
test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => {
|
||||
assert.equal(
|
||||
isCaptchaRejectedResult({
|
||||
iRet: -100,
|
||||
sMsg: '验证码错误',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
isCaptchaRejectedResult({
|
||||
popup: {
|
||||
text: '兑换结果',
|
||||
detail: '兑换码已使用。',
|
||||
},
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
@@ -1,718 +0,0 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
type BrowserResponseLike = {
|
||||
url: () => string
|
||||
request: () => { method: () => string }
|
||||
text: () => Promise<string>
|
||||
status: () => number
|
||||
}
|
||||
|
||||
type BrowserLocatorLike = {
|
||||
screenshot: (options: { path: string }) => Promise<void>
|
||||
getAttribute: (name: string) => Promise<string | null>
|
||||
click: (options?: JsonObject) => Promise<void>
|
||||
evaluate: <T = unknown>(callback: (element: Element) => T | Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
type BrowserPageLike = {
|
||||
waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise<unknown>
|
||||
waitForResponse: (
|
||||
predicate: (response: BrowserResponseLike) => boolean,
|
||||
options?: JsonObject,
|
||||
) => Promise<BrowserResponseLike>
|
||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
||||
locator: (selector: string) => BrowserLocatorLike
|
||||
evaluate: <T = any>(callback: any, arg?: any) => Promise<T>
|
||||
}
|
||||
|
||||
type ActivityInfo = {
|
||||
role?: JsonObject & {
|
||||
ready?: boolean
|
||||
area?: string
|
||||
}
|
||||
form?: {
|
||||
cdkeyInputId?: string
|
||||
verifyInputId?: string
|
||||
verifyImgId?: string
|
||||
submitId?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TencentRedeemPopup = {
|
||||
visible?: boolean
|
||||
text?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type TencentRedeemResult = JsonObject & {
|
||||
iRet?: number | string
|
||||
sMsg?: string
|
||||
msg?: string
|
||||
popup?: TencentRedeemPopup | null
|
||||
}
|
||||
|
||||
type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed'
|
||||
|
||||
type TencentRedeemClassification = {
|
||||
outcome: TencentRedeemOutcome
|
||||
success: boolean
|
||||
retryWithSameCode: boolean
|
||||
retryWithReplacementCode: boolean
|
||||
retCode: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
type RedeemAttemptRecord = {
|
||||
attempt: number
|
||||
verifyCode: string
|
||||
verifysession: string
|
||||
ocrSample: unknown
|
||||
redeem: TencentRedeemResult
|
||||
classification: TencentRedeemClassification
|
||||
role: ActivityInfo['role']
|
||||
}
|
||||
|
||||
type TencentBrowserRedeemSession = JsonObject & {
|
||||
status: string
|
||||
notice: string
|
||||
updatedAt: string
|
||||
lastError?: string
|
||||
lastRedeem?: JsonObject
|
||||
page: BrowserPageLike
|
||||
browserContext: unknown
|
||||
sessionDir: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
type RunTencentBrowserRedeemOptions = {
|
||||
session: TencentBrowserRedeemSession
|
||||
code: string
|
||||
maxAttempts: number
|
||||
ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise<unknown>
|
||||
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
|
||||
fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise<unknown>
|
||||
capturePageCaptchaForOcr: (
|
||||
page: BrowserPageLike,
|
||||
options: { sessionDir: string; attempt: number },
|
||||
) => Promise<{
|
||||
imageBuffer: Buffer
|
||||
imagePath: string
|
||||
imageExtension: string
|
||||
contentType: string
|
||||
verifyImgId: string
|
||||
verifysession: string
|
||||
}>
|
||||
recognizeTencentCaptcha: (payload: JsonObject) => Promise<JsonObject>
|
||||
submitRedeemInBrowser: (
|
||||
page: BrowserPageLike,
|
||||
payload: { code: string; verifyCode: string },
|
||||
) => Promise<TencentRedeemResult>
|
||||
isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean
|
||||
refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise<void>
|
||||
persistSessionState: (session: TencentBrowserRedeemSession) => Promise<unknown>
|
||||
buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject
|
||||
saveRedeemArtifacts: (payload: JsonObject) => Promise<JsonObject>
|
||||
activityUrl: string
|
||||
}
|
||||
|
||||
type ActivityInfoProvider = {
|
||||
ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise<ActivityInfo>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Milo?: {
|
||||
get?: (key: string) => unknown
|
||||
}
|
||||
closeDialog?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTencentBrowserRedeem({
|
||||
session,
|
||||
code,
|
||||
maxAttempts,
|
||||
ensureLoggedInPresentation,
|
||||
ensureActivityInfoReady,
|
||||
fillRedeemCodeInBrowser,
|
||||
capturePageCaptchaForOcr,
|
||||
recognizeTencentCaptcha,
|
||||
submitRedeemInBrowser,
|
||||
isCaptchaRejectedResult,
|
||||
refreshPageCaptcha,
|
||||
persistSessionState,
|
||||
buildSessionPayload,
|
||||
saveRedeemArtifacts,
|
||||
activityUrl,
|
||||
}: RunTencentBrowserRedeemOptions): Promise<JsonObject> {
|
||||
session.status = 'redeeming'
|
||||
session.notice = '正在识别验证码并提交兑换'
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
const attempts: RedeemAttemptRecord[] = []
|
||||
let finalResult: RedeemAttemptRecord | null = null
|
||||
|
||||
try {
|
||||
await ensureLoggedInPresentation(session, { credentialReady: true })
|
||||
const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true })
|
||||
|
||||
if (!activityInfo?.role?.ready) {
|
||||
throw new Error('浏览器会话尚未拿到角色信息,请稍后重试')
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
await fillRedeemCodeInBrowser(session.page, code)
|
||||
|
||||
const captcha = await capturePageCaptchaForOcr(session.page, {
|
||||
sessionDir: session.sessionDir,
|
||||
attempt,
|
||||
})
|
||||
const ocr = await recognizeTencentCaptcha({
|
||||
imageBase64: captcha.imageBuffer.toString('base64'),
|
||||
imageExtension: captcha.imageExtension,
|
||||
imageContentType: captcha.contentType,
|
||||
finalUrl: captcha.imagePath,
|
||||
saveSample: true,
|
||||
tag: `browser-session-${session.sessionId}-attempt-${attempt}`,
|
||||
})
|
||||
|
||||
if (ocr?.code !== 0) {
|
||||
throw new Error(ocr?.msg || 'OCR 识别失败')
|
||||
}
|
||||
|
||||
const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim()
|
||||
|
||||
if (!verifyCode) {
|
||||
throw new Error('OCR 没有识别出验证码')
|
||||
}
|
||||
|
||||
const redeem = await submitRedeemInBrowser(session.page, {
|
||||
code,
|
||||
verifyCode,
|
||||
})
|
||||
const classification = classifyTencentRedeemResult(redeem)
|
||||
|
||||
const attemptRecord = {
|
||||
attempt,
|
||||
verifyCode,
|
||||
verifysession: captcha.verifysession,
|
||||
ocrSample: ocr?.data?.saved || null,
|
||||
redeem,
|
||||
classification,
|
||||
role: activityInfo.role,
|
||||
}
|
||||
|
||||
attempts.push(attemptRecord)
|
||||
finalResult = attemptRecord
|
||||
|
||||
if (!isCaptchaRejectedResult(redeem)) {
|
||||
break
|
||||
}
|
||||
|
||||
await dismissRedeemRetryPopup(session.page)
|
||||
await refreshPageCaptcha(session.page, captcha.verifyImgId)
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
throw new Error('浏览器会话兑换没有拿到结果')
|
||||
}
|
||||
|
||||
const artifacts = await saveRedeemArtifacts({
|
||||
browserContext: session.browserContext,
|
||||
page: session.page,
|
||||
sessionDir: session.sessionDir,
|
||||
sessionId: session.sessionId,
|
||||
activityUrl,
|
||||
code,
|
||||
finalResult,
|
||||
attempts,
|
||||
})
|
||||
|
||||
session.lastRedeem = {
|
||||
code,
|
||||
area: finalResult?.role?.area || '',
|
||||
attempts,
|
||||
final: finalResult,
|
||||
proofMode: artifacts.proofMode,
|
||||
screenshotPath: artifacts.screenshotPath,
|
||||
htmlPath: artifacts.htmlPath,
|
||||
resultPath: artifacts.resultPath,
|
||||
finishedAt: new Date().toISOString(),
|
||||
}
|
||||
session.status = 'redeemed'
|
||||
session.notice = resolveTencentRedeemMessage(finalResult?.redeem)
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
|
||||
return {
|
||||
...buildSessionPayload(session),
|
||||
redeem: session.lastRedeem,
|
||||
}
|
||||
} catch (error) {
|
||||
session.status = 'failed'
|
||||
session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败'
|
||||
session.lastError = session.notice
|
||||
session.updatedAt = new Date().toISOString()
|
||||
await persistSessionState(session)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function capturePageCaptchaForOcr(
|
||||
page: BrowserPageLike,
|
||||
{ sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider,
|
||||
) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 })
|
||||
|
||||
if (!activityInfo?.form?.verifyImgId) {
|
||||
throw new Error('页面里没有找到验证码图片节点')
|
||||
}
|
||||
|
||||
const verifySelector = `#${activityInfo.form.verifyImgId}`
|
||||
|
||||
await page.waitForFunction(
|
||||
(selector) => {
|
||||
const element = document.querySelector(selector)
|
||||
|
||||
if (!(element instanceof HTMLImageElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
element.naturalWidth > 0
|
||||
)
|
||||
},
|
||||
verifySelector,
|
||||
{ timeout: 8_000 },
|
||||
)
|
||||
|
||||
const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png`
|
||||
await page.locator(verifySelector).screenshot({ path: imagePath })
|
||||
|
||||
const verifysession = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.Milo && typeof window.Milo.get === 'function') {
|
||||
return String(window.Milo.get('verifysession') || '')
|
||||
}
|
||||
} catch {
|
||||
// ignore Milo access failures
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
return {
|
||||
imageBuffer: await fs.readFile(imagePath),
|
||||
imagePath,
|
||||
imageExtension: '.png',
|
||||
contentType: 'image/png',
|
||||
verifyImgId: activityInfo.form.verifyImgId,
|
||||
verifysession,
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitRedeemInBrowser(
|
||||
page: BrowserPageLike,
|
||||
payload: { code: string; verifyCode: string },
|
||||
{ ensureActivityInfoReady }: ActivityInfoProvider,
|
||||
): Promise<TencentRedeemResult> {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 })
|
||||
|
||||
if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) {
|
||||
throw new Error('页面里没有找到兑换输入框或提交按钮')
|
||||
}
|
||||
|
||||
const verifySelector = `#${activityInfo.form.verifyInputId}`
|
||||
const submitSelector = `#${activityInfo.form.submitId}`
|
||||
|
||||
await setFormControlValue(page, verifySelector, payload.verifyCode)
|
||||
await resetRedeemPopup(page)
|
||||
await normalizeRedeemOverlay(page)
|
||||
|
||||
const responsePromise = page
|
||||
.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('dfm.ams.game.qq.com/ide/') &&
|
||||
response.request().method().toUpperCase() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null)
|
||||
const popupPromise = waitForRedeemPopup(page).catch(() => null)
|
||||
|
||||
await clickRedeemSubmit(page, submitSelector)
|
||||
|
||||
const networkResponse = await responsePromise
|
||||
const popup = await popupPromise
|
||||
const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null
|
||||
|
||||
if (parsedNetwork && typeof parsedNetwork === 'object') {
|
||||
return {
|
||||
httpStatus: networkResponse.status(),
|
||||
popup: popup || null,
|
||||
...parsedNetwork,
|
||||
}
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('页面内兑换没有等到结果弹窗或接口响应')
|
||||
}
|
||||
|
||||
return {
|
||||
httpStatus: networkResponse?.status?.() || 0,
|
||||
iRet: inferRedeemCodeFromPopup(popup),
|
||||
sMsg: popup.text || popup.detail || '兑换完成',
|
||||
popup,
|
||||
}
|
||||
}
|
||||
|
||||
export function fillRedeemCodeInBrowser(
|
||||
page: BrowserPageLike,
|
||||
code: string,
|
||||
{ activityInfo }: { activityInfo?: ActivityInfo } = {},
|
||||
) {
|
||||
const cdkeySelector = activityInfo?.form?.cdkeyInputId
|
||||
? `#${activityInfo.form.cdkeyInputId}`
|
||||
: '[id^="milo_cdkeyInfo_"]'
|
||||
|
||||
const verifySelector = activityInfo?.form?.verifyInputId
|
||||
? `#${activityInfo.form.verifyInputId}`
|
||||
: '[id^="milo_verifyInput_"]'
|
||||
|
||||
return Promise.all([
|
||||
setFormControlValue(page, cdkeySelector, code),
|
||||
setFormControlValue(page, verifySelector, ''),
|
||||
])
|
||||
}
|
||||
|
||||
export async function prepareRedeemCodeFill(
|
||||
page: BrowserPageLike,
|
||||
code: string,
|
||||
{ ensureActivityInfoReady }: ActivityInfoProvider,
|
||||
) {
|
||||
const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 })
|
||||
await fillRedeemCodeInBrowser(page, code, { activityInfo })
|
||||
}
|
||||
|
||||
export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) {
|
||||
if (!verifyImgId) {
|
||||
return
|
||||
}
|
||||
|
||||
const selector = `#${verifyImgId}`
|
||||
const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '')
|
||||
await page.locator(selector).click({ timeout: 3_000 }).catch(() => null)
|
||||
await page.waitForFunction(
|
||||
({ selector: targetSelector, previousSrc }) => {
|
||||
const element = document.querySelector(targetSelector)
|
||||
|
||||
if (!(element instanceof HTMLImageElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '')
|
||||
},
|
||||
{ selector, previousSrc: currentSrc || '' },
|
||||
{ timeout: 5_000 },
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean {
|
||||
if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification {
|
||||
const retCode = Number(result?.iRet)
|
||||
const message = resolveTencentRedeemMessage(result)
|
||||
const normalized = normalizeTencentRedeemText(message)
|
||||
|
||||
if (Number.isFinite(retCode) && retCode === -100) {
|
||||
return buildTencentRedeemClassification('captcha_rejected', retCode, message)
|
||||
}
|
||||
|
||||
if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) {
|
||||
return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用')
|
||||
}
|
||||
|
||||
if (
|
||||
retCode === -165 ||
|
||||
retCode === -183 ||
|
||||
/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized)
|
||||
) {
|
||||
return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确')
|
||||
}
|
||||
|
||||
if (/验证码|校验码/.test(normalized)) {
|
||||
return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试')
|
||||
}
|
||||
|
||||
if (
|
||||
retCode === 0 ||
|
||||
/成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized)
|
||||
) {
|
||||
return buildTencentRedeemClassification('success', retCode, message || '兑换成功')
|
||||
}
|
||||
|
||||
return buildTencentRedeemClassification('failed', retCode, message || '兑换失败')
|
||||
}
|
||||
|
||||
function resetRedeemPopup(page: BrowserPageLike) {
|
||||
return page.evaluate(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
const popupDetail = document.querySelector('#PopText2')
|
||||
|
||||
if (popup instanceof HTMLElement) {
|
||||
popup.style.display = 'none'
|
||||
}
|
||||
|
||||
if (popupText instanceof HTMLElement) {
|
||||
popupText.textContent = ''
|
||||
}
|
||||
|
||||
if (popupDetail instanceof HTMLElement) {
|
||||
popupDetail.classList.add('hide')
|
||||
popupDetail.textContent = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function dismissRedeemRetryPopup(page: BrowserPageLike) {
|
||||
await page.evaluate(() => {
|
||||
try {
|
||||
if (typeof window.closeDialog === 'function') {
|
||||
window.closeDialog()
|
||||
}
|
||||
} catch {
|
||||
// ignore page close hook failures
|
||||
}
|
||||
|
||||
const closeButton = document.querySelector('#pop2 .pop_close')
|
||||
|
||||
if (closeButton instanceof HTMLElement) {
|
||||
closeButton.click()
|
||||
}
|
||||
|
||||
const popup = document.querySelector('#pop2')
|
||||
|
||||
if (popup instanceof HTMLElement) {
|
||||
popup.style.display = 'none'
|
||||
popup.style.visibility = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
await normalizeRedeemOverlay(page)
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
|
||||
async function normalizeRedeemOverlay(page: BrowserPageLike) {
|
||||
await page.evaluate(() => {
|
||||
const overlayIds = ['_overlay_', 'overlay_mask', 'overlay']
|
||||
|
||||
for (const id of overlayIds) {
|
||||
const element = document.getElementById(id)
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue
|
||||
}
|
||||
|
||||
element.style.pointerEvents = 'none'
|
||||
element.style.display = 'none'
|
||||
element.style.opacity = '0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) {
|
||||
const locator = page.locator(submitSelector)
|
||||
|
||||
try {
|
||||
await locator.click({ timeout: 3_000 })
|
||||
return
|
||||
} catch {
|
||||
await normalizeRedeemOverlay(page)
|
||||
}
|
||||
|
||||
try {
|
||||
await locator.click({ timeout: 3_000, force: true })
|
||||
return
|
||||
} catch {
|
||||
await locator.evaluate((element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.click()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) {
|
||||
const nextValue = String(value ?? '')
|
||||
|
||||
const appliedValue = await page.evaluate(
|
||||
({ targetSelector, targetValue }) => {
|
||||
const isVisible = (element: Element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0' &&
|
||||
element.getClientRects().length > 0
|
||||
)
|
||||
}
|
||||
|
||||
const matches = [...document.querySelectorAll(targetSelector)]
|
||||
const element =
|
||||
matches.find(
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden' &&
|
||||
isVisible(node),
|
||||
) ||
|
||||
matches.find(
|
||||
(node) =>
|
||||
(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) &&
|
||||
!node.disabled &&
|
||||
node.type !== 'hidden',
|
||||
) ||
|
||||
null
|
||||
|
||||
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) {
|
||||
return {
|
||||
found: false,
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
|
||||
const prototype =
|
||||
element instanceof HTMLTextAreaElement
|
||||
? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
|
||||
|
||||
if (descriptor?.set) {
|
||||
descriptor.set.call(element, targetValue)
|
||||
} else {
|
||||
element.value = targetValue
|
||||
}
|
||||
|
||||
element.setAttribute('value', targetValue)
|
||||
element.focus()
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }))
|
||||
element.blur()
|
||||
|
||||
return {
|
||||
found: true,
|
||||
value: String(element.value || ''),
|
||||
}
|
||||
},
|
||||
{ targetSelector: selector, targetValue: nextValue },
|
||||
)
|
||||
|
||||
if (!appliedValue?.found) {
|
||||
throw new Error(`页面里没有找到可填写的表单节点: ${selector}`)
|
||||
}
|
||||
|
||||
if (String(appliedValue.value || '') !== nextValue) {
|
||||
throw new Error(`页面表单写值失败: ${selector}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRedeemPopup(page: BrowserPageLike): Promise<TencentRedeemPopup> {
|
||||
await page.waitForFunction(() => {
|
||||
const popup = document.querySelector('#pop2')
|
||||
const popupText = document.querySelector('#PopText')
|
||||
|
||||
if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(popup)
|
||||
return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
return page.evaluate(() => ({
|
||||
visible: true,
|
||||
text: String(document.querySelector('#PopText')?.textContent || '').trim(),
|
||||
detail: String(document.querySelector('#PopText2')?.textContent || '').trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
function tryParseJson(text: string): any {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) {
|
||||
const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim()
|
||||
|
||||
if (!text) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (/验证码|校验码/.test(text)) {
|
||||
return -100
|
||||
}
|
||||
|
||||
if (/成功|已兑换|领取成功/.test(text)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export function resolveTencentRedeemMessage(result: TencentRedeemResult): string {
|
||||
const candidates = [
|
||||
result?.popup?.detail,
|
||||
result?.popup?.text,
|
||||
result?.sMsg,
|
||||
result?.msg,
|
||||
]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return candidates[0] || '兑换完成'
|
||||
}
|
||||
|
||||
function buildTencentRedeemClassification(
|
||||
outcome: TencentRedeemOutcome,
|
||||
retCode: number,
|
||||
message: string,
|
||||
): TencentRedeemClassification {
|
||||
return {
|
||||
outcome,
|
||||
success: outcome === 'success',
|
||||
retryWithSameCode: outcome === 'captcha_rejected',
|
||||
retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid',
|
||||
retCode: Number.isFinite(retCode) ? retCode : null,
|
||||
message: String(message || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTencentRedeemText(value: unknown): string {
|
||||
return String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { logBrowserSessionDebug } from './session-login-shared.js'
|
||||
|
||||
type BrowserPageLike = {
|
||||
screenshot: (options: { path: string; fullPage?: boolean }) => Promise<unknown>
|
||||
}
|
||||
|
||||
type ReviewSession = {
|
||||
sessionId: string
|
||||
loginType: string
|
||||
sessionDir: string
|
||||
page: BrowserPageLike
|
||||
lastReview?: {
|
||||
screenshotPath?: string
|
||||
signature?: string
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
type ActivityInfoForReview = {
|
||||
role?: {
|
||||
roleId?: string
|
||||
roleName?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReviewSignature(session: Pick<ReviewSession, 'loginType'>, activityInfo: ActivityInfoForReview) {
|
||||
const roleId = String(activityInfo?.role?.roleId || '').trim()
|
||||
const roleName = String(activityInfo?.role?.roleName || '').trim()
|
||||
|
||||
return {
|
||||
roleId,
|
||||
roleName,
|
||||
signature: `${session.loginType}|${roleId}|${roleName}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureReviewScreenshot(session: ReviewSession, activityInfo: ActivityInfoForReview): Promise<boolean> {
|
||||
const { roleId, roleName, signature } = resolveReviewSignature(session, activityInfo)
|
||||
|
||||
if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) {
|
||||
return false
|
||||
}
|
||||
|
||||
const screenshotPath = path.join(session.sessionDir, 'role-review.png')
|
||||
|
||||
try {
|
||||
await session.page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage: true,
|
||||
})
|
||||
session.lastReview = {
|
||||
screenshotPath,
|
||||
capturedAt: new Date().toISOString(),
|
||||
roleId,
|
||||
roleName,
|
||||
signature,
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logBrowserSessionDebug('reviewScreenshot.capture.error', {
|
||||
sessionId: session.sessionId,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { resolveDefaultViewport } from './session-browser-config.js'
|
||||
|
||||
export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions')
|
||||
export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
|
||||
export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
|
||||
export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000
|
||||
export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution)
|
||||
export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
|
||||
@@ -1,102 +0,0 @@
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import {
|
||||
buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase,
|
||||
resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase,
|
||||
} from './session-browser-config.js'
|
||||
import { CHROME_PATH } from './session-service-config.js'
|
||||
|
||||
type BrowserLike = {
|
||||
on: (event: 'disconnected', listener: () => void) => void
|
||||
close: () => Promise<void>
|
||||
newContext: (options?: Record<string, unknown>) => Promise<any>
|
||||
}
|
||||
|
||||
type SessionServiceErrorOptions = {
|
||||
statusCode?: number
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
let browserPromise: Promise<BrowserLike> | null = null
|
||||
|
||||
export class SessionServiceError extends Error {
|
||||
statusCode: number
|
||||
errorCode: string
|
||||
|
||||
constructor(message: string, { statusCode = 500, errorCode = 'session_error' }: SessionServiceErrorOptions = {}) {
|
||||
super(message)
|
||||
this.name = 'SessionServiceError'
|
||||
this.statusCode = statusCode
|
||||
this.errorCode = errorCode
|
||||
}
|
||||
}
|
||||
|
||||
export async function warmupTencentBrowser() {
|
||||
const browserLaunch = resolveBrowserLaunchOptions()
|
||||
|
||||
if (!browserLaunch.prewarm) {
|
||||
return {
|
||||
warmed: false,
|
||||
reason: 'disabled',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBrowser()
|
||||
|
||||
return {
|
||||
warmed: true,
|
||||
reason: 'ready',
|
||||
browserLaunch,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrowser(): Promise<BrowserLike> {
|
||||
if (!browserPromise) {
|
||||
const launchOptions = resolveBrowserLaunchOptions()
|
||||
browserPromise = (chromium.launch(buildChromiumLaunchOptions(launchOptions)) as Promise<BrowserLike>)
|
||||
.then((browser) => {
|
||||
browser.on('disconnected', () => {
|
||||
browserPromise = null
|
||||
})
|
||||
return browser
|
||||
})
|
||||
.catch((error) => {
|
||||
browserPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return browserPromise
|
||||
}
|
||||
|
||||
export function resolveBrowserLaunchOptions() {
|
||||
return resolveBrowserLaunchOptionsBase({
|
||||
browserConfig: runtimeConfig.browser,
|
||||
chromePath: CHROME_PATH,
|
||||
})
|
||||
}
|
||||
|
||||
export async function closeBrowserIfIdle({ activeSessionCount = 0 }: { activeSessionCount?: number } = {}): Promise<void> {
|
||||
if (activeSessionCount > 0 || !browserPromise) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolveBrowserLaunchOptions().keepAlive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await browserPromise
|
||||
await browser.close()
|
||||
} catch {
|
||||
// ignore browser close failures
|
||||
} finally {
|
||||
browserPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function buildChromiumLaunchOptions(launchOptions: ReturnType<typeof resolveBrowserLaunchOptionsBase>) {
|
||||
return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH })
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
BROWSER_SESSION_DATA_ROOT,
|
||||
REDEEM_SUCCESS_AUTO_CLOSE_MS,
|
||||
} from './session-service-config.js'
|
||||
import { SessionServiceError } from './session-service-runtime.js'
|
||||
import {
|
||||
resolveInitialQrSessionState,
|
||||
SESSION_TTL_MS,
|
||||
} from './session-state.js'
|
||||
|
||||
type BrowserContextLike = {
|
||||
close?: () => Promise<void>
|
||||
}
|
||||
|
||||
export type TencentBrowserSession = Record<string, any> & {
|
||||
sessionId: string
|
||||
loginType: string
|
||||
status: string
|
||||
notice: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
expiresAt: number
|
||||
autoCloseAt: number
|
||||
autoCloseTimer: ReturnType<typeof setTimeout> | null
|
||||
sessionDir: string
|
||||
browserContext: any
|
||||
page: any
|
||||
browserContextClosed?: boolean
|
||||
presentationSyncAttempts: number
|
||||
}
|
||||
|
||||
type RegisterSessionInput = {
|
||||
loginType: string
|
||||
browserContext: BrowserContextLike
|
||||
page: unknown
|
||||
}
|
||||
|
||||
type CloseSessionDependency = {
|
||||
closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise<void>
|
||||
}
|
||||
|
||||
type CloseSessionOptions = {
|
||||
markClosed: boolean
|
||||
persistSessionState: (session: TencentBrowserSession) => Promise<unknown>
|
||||
closeBrowserIfIdle: () => Promise<unknown>
|
||||
}
|
||||
|
||||
type PersistSessionStateOptions = {
|
||||
buildSessionPayload: (session: TencentBrowserSession) => unknown
|
||||
}
|
||||
|
||||
const sessions = new Map<string, TencentBrowserSession>()
|
||||
|
||||
export async function registerTencentBrowserSession({
|
||||
loginType,
|
||||
browserContext,
|
||||
page,
|
||||
}: RegisterSessionInput): Promise<TencentBrowserSession> {
|
||||
const sessionId = createSessionId()
|
||||
const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId)
|
||||
await fs.mkdir(sessionDir, { recursive: true })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const session = {
|
||||
sessionId,
|
||||
loginType,
|
||||
status: 'created',
|
||||
notice: '浏览器会话已创建',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
autoCloseAt: 0,
|
||||
autoCloseTimer: null,
|
||||
sessionDir,
|
||||
browserContext,
|
||||
page,
|
||||
qrImageBase64: '',
|
||||
qrImagePath: '',
|
||||
qrUpdatedAt: '',
|
||||
lastState: null,
|
||||
lastRedeem: null,
|
||||
lastReview: null,
|
||||
lastError: '',
|
||||
presentationSyncAttempts: 0,
|
||||
}
|
||||
|
||||
sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
export function listActiveTencentBrowserSessions(): TencentBrowserSession[] {
|
||||
return [...sessions.values()]
|
||||
}
|
||||
|
||||
export async function getRequiredSession(sessionId: unknown): Promise<TencentBrowserSession> {
|
||||
const key = String(sessionId || '').trim()
|
||||
|
||||
if (!key) {
|
||||
throw new SessionServiceError('缺少 sessionId', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_session_id',
|
||||
})
|
||||
}
|
||||
|
||||
const session = sessions.get(key)
|
||||
|
||||
if (!session) {
|
||||
throw new SessionServiceError('浏览器会话不存在或已过期', {
|
||||
statusCode: 404,
|
||||
errorCode: 'session_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export function applyInitialQrSessionState(session: TencentBrowserSession): void {
|
||||
resetTencentSessionAutoClose(session)
|
||||
Object.assign(session, resolveInitialQrSessionState(session.loginType))
|
||||
}
|
||||
|
||||
export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void {
|
||||
resetTencentSessionAutoClose(session)
|
||||
|
||||
const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS
|
||||
session.autoCloseAt = autoCloseAt
|
||||
session.expiresAt = autoCloseAt
|
||||
session.autoCloseTimer = setTimeout(() => {
|
||||
void closeSession(session, { markClosed: true }).catch(() => {
|
||||
// ignore delayed auto-close failures
|
||||
})
|
||||
}, REDEEM_SUCCESS_AUTO_CLOSE_MS)
|
||||
}
|
||||
|
||||
export function resetTencentSessionAutoClose(session?: TencentBrowserSession | null): void {
|
||||
if (session?.autoCloseTimer) {
|
||||
clearTimeout(session.autoCloseTimer)
|
||||
session.autoCloseTimer = null
|
||||
}
|
||||
|
||||
if (session) {
|
||||
session.autoCloseAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise<void> {
|
||||
const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now())
|
||||
|
||||
await Promise.all(expired.map((session) => closeSession(session, { markClosed: false })))
|
||||
}
|
||||
|
||||
export async function closeSession(
|
||||
session: TencentBrowserSession,
|
||||
{
|
||||
markClosed,
|
||||
persistSessionState,
|
||||
closeBrowserIfIdle,
|
||||
}: CloseSessionOptions,
|
||||
): Promise<void> {
|
||||
resetTencentSessionAutoClose(session)
|
||||
sessions.delete(session.sessionId)
|
||||
|
||||
try {
|
||||
await session.browserContext?.close()
|
||||
} catch {
|
||||
// ignore browser context close failures
|
||||
}
|
||||
|
||||
session.browserContextClosed = true
|
||||
session.updatedAt = new Date().toISOString()
|
||||
|
||||
if (markClosed) {
|
||||
session.status = 'closed'
|
||||
session.notice = '浏览器会话已关闭'
|
||||
await persistSessionState(session)
|
||||
}
|
||||
|
||||
await closeBrowserIfIdle()
|
||||
}
|
||||
|
||||
export async function persistSessionState(
|
||||
session: TencentBrowserSession,
|
||||
{ buildSessionPayload }: PersistSessionStateOptions,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(session.sessionDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(session.sessionDir, 'session.json'),
|
||||
JSON.stringify(buildSessionPayload(session), null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function createSessionId(): string {
|
||||
let sessionId = ''
|
||||
|
||||
do {
|
||||
sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}`
|
||||
} while (sessions.has(sessionId))
|
||||
|
||||
return sessionId
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user