完善接单平台上传与物品规则
This commit is contained in:
@@ -23,6 +23,16 @@ LOG_LEVEL=info
|
||||
LOG_INTEGRATION_LEVEL=warn
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# File Storage (MinIO / S3 compatible)
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_BUCKET=order-site
|
||||
STORAGE_ACCESS_KEY_ID=minioadmin
|
||||
STORAGE_SECRET_ACCESS_KEY=minioadmin
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
||||
MINIO_API_PORT=19000
|
||||
MINIO_CONSOLE_PORT=19001
|
||||
|
||||
# Admin
|
||||
ADMIN_SESSION_SECRET=dev-local-session-secret
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
|
||||
|
||||
@@ -28,6 +28,14 @@ LOG_LEVEL=info
|
||||
LOG_INTEGRATION_LEVEL=warn
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# File Storage (MinIO / S3 compatible)
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_BUCKET=order-site
|
||||
STORAGE_ACCESS_KEY_ID=change-me-storage-access-key
|
||||
STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
||||
|
||||
# Admin
|
||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||
|
||||
Generated
+1090
-1
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"node-pg-migrate": "^8.0.4",
|
||||
"pg": "^8.16.3"
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
+66
-73
@@ -1,95 +1,87 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import express from "express";
|
||||
import process from "node:process";
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import express from 'express'
|
||||
import process from 'node:process'
|
||||
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import collectRouter from "./routes/collect.js";
|
||||
import claimsRouter from "./routes/claims.js";
|
||||
import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js";
|
||||
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
|
||||
import open91Router from "./routes/open-91.js";
|
||||
import shortLinksRouter from "./routes/short-links.js";
|
||||
import workerRouter from "./routes/worker.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";
|
||||
import adminRouter from './routes/admin.js'
|
||||
import collectRouter from './routes/collect.js'
|
||||
import filesRouter from './routes/files.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
import kuaishouFeifeiRouter from './routes/kuaishou-feifei.js'
|
||||
import kuaishouIndustryRouter from './routes/kuaishou-industry.js'
|
||||
import open91Router from './routes/open-91.js'
|
||||
import shortLinksRouter from './routes/short-links.js'
|
||||
import workerRouter from './routes/worker.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 CreateAppOptions = {
|
||||
startupState: StartupState;
|
||||
isShutdownStarted: () => boolean;
|
||||
config: RuntimeConfig;
|
||||
};
|
||||
startupState: StartupState
|
||||
isShutdownStarted: () => boolean
|
||||
config: RuntimeConfig
|
||||
}
|
||||
|
||||
export function createApp({
|
||||
startupState,
|
||||
isShutdownStarted,
|
||||
config,
|
||||
}: CreateAppOptions) {
|
||||
const app = express();
|
||||
export function createApp({ startupState, isShutdownStarted, config }: CreateAppOptions) {
|
||||
const app = express()
|
||||
|
||||
app.use(accessLogMiddleware);
|
||||
app.use(createCorsMiddleware(config));
|
||||
app.use(accessLogMiddleware)
|
||||
app.use(createCorsMiddleware(config))
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "2mb",
|
||||
limit: '2mb',
|
||||
verify: (req, _res, buffer) => {
|
||||
(req as Request).rawBody = buffer.toString("utf8");
|
||||
;(req as Request).rawBody = buffer.toString('utf8')
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
}),
|
||||
)
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json(
|
||||
buildSuccessPayload(
|
||||
buildHealthPayload(startupState, isShutdownStarted()),
|
||||
startupState.core.ready ? "ready" : "starting"
|
||||
)
|
||||
);
|
||||
});
|
||||
startupState.core.ready ? 'ready' : 'starting',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
app.get("/health/live", (_req, res) => {
|
||||
app.get('/health/live', (_req, res) => {
|
||||
res.json(
|
||||
buildSuccessPayload({
|
||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||
status: isShutdownStarted() ? 'shutting_down' : 'alive',
|
||||
pid: process.pid,
|
||||
mode: "kuaishou-lite",
|
||||
})
|
||||
);
|
||||
});
|
||||
mode: 'kuaishou-lite',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
app.get("/health/ready", (_req, res) => {
|
||||
app.get('/health/ready', (_req, res) => {
|
||||
if (startupState.core.ready) {
|
||||
res.json(
|
||||
buildSuccessPayload(
|
||||
buildHealthPayload(startupState, isShutdownStarted()),
|
||||
"ready"
|
||||
)
|
||||
);
|
||||
return;
|
||||
res.json(buildSuccessPayload(buildHealthPayload(startupState, isShutdownStarted()), 'ready'))
|
||||
return
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
code: 1,
|
||||
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
||||
errorCode: "service_not_ready",
|
||||
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;
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
code: 1,
|
||||
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
||||
errorCode: "service_not_ready",
|
||||
msg: startupState.core.lastError || '服务启动中,请稍后重试',
|
||||
errorCode: 'service_not_ready',
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
data: {
|
||||
startup: {
|
||||
@@ -98,21 +90,22 @@ export function createApp({
|
||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
app.use("/api/v1/open/91", open91Router);
|
||||
app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter);
|
||||
app.use("/api/v1/open/kuaishou-feifei", kuaishouFeifeiRouter);
|
||||
app.use("/api/v1/claim", claimsRouter);
|
||||
app.use("/api/v1/collect", collectRouter);
|
||||
app.use("/api/v1/worker", workerRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
app.use("/s", shortLinksRouter);
|
||||
app.use('/api/v1/open/91', open91Router)
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter)
|
||||
app.use('/api/v1/claim', claimsRouter)
|
||||
app.use('/api/v1/collect', collectRouter)
|
||||
app.use('/api/v1/files', filesRouter)
|
||||
app.use('/api/v1/worker', workerRouter)
|
||||
app.use('/api/v1/admin', adminRouter)
|
||||
app.use('/s', shortLinksRouter)
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
sendRouteError(res, err, "服务内部错误", "[global]");
|
||||
});
|
||||
sendRouteError(res, err, '服务内部错误', '[global]')
|
||||
})
|
||||
|
||||
return app;
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import path from 'node:path'
|
||||
|
||||
import type { RuntimeConfig } from "../types/runtime-config.js";
|
||||
import type { RuntimeConfig } from '../types/runtime-config.js'
|
||||
|
||||
export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
return {
|
||||
@@ -9,99 +9,107 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
},
|
||||
|
||||
data: {
|
||||
root: path.resolve(projectRoot, "data"),
|
||||
root: path.resolve(projectRoot, 'data'),
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: "info",
|
||||
level: 'info',
|
||||
// 对接日志默认只保留 warn/error,避免 integration-*.log 被 info 刷爆
|
||||
integrationLevel: "warn",
|
||||
integrationLevel: 'warn',
|
||||
retentionDays: 30,
|
||||
},
|
||||
|
||||
database: {
|
||||
url: "postgres://postgres:postgres@127.0.0.1:5432/order_site",
|
||||
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
||||
ssl: false,
|
||||
maxConnections: 10,
|
||||
},
|
||||
|
||||
storage: {
|
||||
endpoint: 'http://127.0.0.1:9000',
|
||||
bucket: 'order-site',
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
maxUploadSizeMb: 10,
|
||||
},
|
||||
|
||||
orders: {
|
||||
claimBaseUrl: "http://127.0.0.1:5173/#/claim",
|
||||
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
||||
tokenTtlHours: 24,
|
||||
},
|
||||
|
||||
admin: {
|
||||
sessionSecret: "",
|
||||
sessionSecret: '',
|
||||
sessionTtlHours: 12,
|
||||
defaultUsers: [],
|
||||
},
|
||||
|
||||
platforms: {
|
||||
ninetyone: {
|
||||
userId: "",
|
||||
secret: "",
|
||||
version: "1.0",
|
||||
shopId: "91kaquan",
|
||||
shopName: "91卡券",
|
||||
userId: '',
|
||||
secret: '',
|
||||
version: '1.0',
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
timestampToleranceSeconds: 600,
|
||||
cardsEncoding: "aes-256-ecb-base64",
|
||||
cardsEncoding: 'aes-256-ecb-base64',
|
||||
},
|
||||
kuaishouIndustry: {
|
||||
appKey: "",
|
||||
appSecret: "",
|
||||
signSecret: "",
|
||||
messageSecret: "",
|
||||
provider: "kuaishou-industry",
|
||||
platform: "kuaishou",
|
||||
shopId: "kuaishou-industry",
|
||||
shopName: "快手行业电子凭证",
|
||||
version: "1",
|
||||
appKey: '',
|
||||
appSecret: '',
|
||||
signSecret: '',
|
||||
messageSecret: '',
|
||||
provider: 'kuaishou-industry',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'kuaishou-industry',
|
||||
shopName: '快手行业电子凭证',
|
||||
version: '1',
|
||||
},
|
||||
kuaishouFeifei: {
|
||||
enabled: true,
|
||||
baseUrl: "http://skin-exchange.yiquyou.icu",
|
||||
appKey: "",
|
||||
appSecret: "",
|
||||
baseUrl: 'http://skin-exchange.yiquyou.icu',
|
||||
appKey: '',
|
||||
appSecret: '',
|
||||
timeoutMs: 10000,
|
||||
notifyUrl: "",
|
||||
notifyUrl: '',
|
||||
productRules: [],
|
||||
},
|
||||
cloudtentacles: {
|
||||
baseUrl: "https://123.207.217.176",
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
timeoutMs: 5000,
|
||||
sendSmsPath: "/public/verif_code",
|
||||
loginPath: "/public/login",
|
||||
userInfoPath: "/user/info",
|
||||
assetPath: "/user/get_asset",
|
||||
permissionPath: "/user/get_permission",
|
||||
categoriesPath: "/categories/get",
|
||||
skuListPath: "/sku/list",
|
||||
skuBuyPath: "/sku/buy",
|
||||
skuUsePath: "/sku/use",
|
||||
knapsackPath: "/user/get_knapsack",
|
||||
vnListPath: "/vn/list",
|
||||
vnAppointPath: "/vn/appoint",
|
||||
vnGenerateLoginCodePath: "/vn/generate_login_code",
|
||||
vnVerifCodePath: "/public/vn_verif_code",
|
||||
vnVerifyLoginCodePath: "/vn/verify_login_code",
|
||||
vnBindUrlPath: "/vn/bind_url",
|
||||
vnBindInfoPath: "/vn/bind_info",
|
||||
vnBackPath: "/vn/back",
|
||||
sendSmsPath: '/public/verif_code',
|
||||
loginPath: '/public/login',
|
||||
userInfoPath: '/user/info',
|
||||
assetPath: '/user/get_asset',
|
||||
permissionPath: '/user/get_permission',
|
||||
categoriesPath: '/categories/get',
|
||||
skuListPath: '/sku/list',
|
||||
skuBuyPath: '/sku/buy',
|
||||
skuUsePath: '/sku/use',
|
||||
knapsackPath: '/user/get_knapsack',
|
||||
vnListPath: '/vn/list',
|
||||
vnAppointPath: '/vn/appoint',
|
||||
vnGenerateLoginCodePath: '/vn/generate_login_code',
|
||||
vnVerifCodePath: '/public/vn_verif_code',
|
||||
vnVerifyLoginCodePath: '/vn/verify_login_code',
|
||||
vnBindUrlPath: '/vn/bind_url',
|
||||
vnBindInfoPath: '/vn/bind_info',
|
||||
vnBackPath: '/vn/back',
|
||||
bindUrlTtlSeconds: 600,
|
||||
bindUrlProbeIntervalSeconds: 30,
|
||||
bindUrlProbeTimeoutMs: 5000,
|
||||
bindUrlProbeUserAgent:
|
||||
"Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000",
|
||||
bindUrlProbeEndpoint: "https://comm.ams.game.qq.com/ide/",
|
||||
bindUrlProbeChartId: "323794",
|
||||
bindUrlProbeSubChartId: "323794",
|
||||
bindUrlProbeIdeToken: "z90Syo",
|
||||
bindUrlProbeActivityUrl:
|
||||
"http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F",
|
||||
bindUrlProbeReferer: "https://gp.qq.com/",
|
||||
bindUrlProbeExtraCookie: "",
|
||||
clientSource: "ct-client",
|
||||
deviceId: "08bc9d8c-fd15-48ea-bc00-8d754076cafc",
|
||||
'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000',
|
||||
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
|
||||
bindUrlProbeChartId: '323794',
|
||||
bindUrlProbeSubChartId: '323794',
|
||||
bindUrlProbeIdeToken: 'z90Syo',
|
||||
bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
|
||||
bindUrlProbeReferer: 'https://gp.qq.com/',
|
||||
bindUrlProbeExtraCookie: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||
deviceType: 1,
|
||||
publicKeyPem: `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5
|
||||
@@ -116,7 +124,7 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
},
|
||||
|
||||
cors: {
|
||||
allowedOrigins: ["*"],
|
||||
allowedOrigins: ['*'],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,7 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const BACKEND_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||
const WORKSPACE_ROOT = path.resolve(BACKEND_ROOT, '../..')
|
||||
|
||||
const SPECIAL_RUNTIME_ENV_NAMES = [
|
||||
'APP_BASE_URL',
|
||||
'CLAIM_BASE_URL',
|
||||
]
|
||||
const SPECIAL_RUNTIME_ENV_NAMES = ['APP_BASE_URL', 'CLAIM_BASE_URL']
|
||||
|
||||
const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'APP_DOMAIN',
|
||||
@@ -21,6 +18,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||
'MINIO_ROOT_PASSWORD',
|
||||
'MINIO_ROOT_USER',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_PASSWORD',
|
||||
|
||||
@@ -1,352 +1,215 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
import type {
|
||||
AdminDefaultUser,
|
||||
KuaishouFeifeiProductRule,
|
||||
RuntimeConfig,
|
||||
} from "../types/runtime-config.js";
|
||||
import {
|
||||
parseBoolean,
|
||||
parseInteger,
|
||||
parseJsonArray,
|
||||
} from "./runtime-env.js";
|
||||
import { deepMerge } from "./runtime-merge.js";
|
||||
} from '../types/runtime-config.js'
|
||||
import { parseBoolean, parseInteger, parseJsonArray } from './runtime-env.js'
|
||||
import { deepMerge } from './runtime-merge.js'
|
||||
|
||||
type RuntimeConfigPath = readonly [string, ...string[]];
|
||||
type RuntimeConfigPath = readonly [string, ...string[]]
|
||||
type RuntimeConfigValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| AdminDefaultUser[]
|
||||
| KuaishouFeifeiProductRule[]
|
||||
| string[];
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
| string[]
|
||||
type RuntimeEnv = Record<string, string | undefined>
|
||||
|
||||
type EnvOverride = {
|
||||
env: string;
|
||||
path: RuntimeConfigPath;
|
||||
read: (rawValue: unknown) => RuntimeConfigValue | null;
|
||||
};
|
||||
env: string
|
||||
path: RuntimeConfigPath
|
||||
read: (rawValue: unknown) => RuntimeConfigValue | null
|
||||
}
|
||||
|
||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv("PORT", ["server", "port"]),
|
||||
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||
stringEnv("LOG_INTEGRATION_LEVEL", ["logging", "integrationLevel"]),
|
||||
integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]),
|
||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||
booleanEnv("DATABASE_SSL", ["database", "ssl"]),
|
||||
integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]),
|
||||
integerEnv("CLAIM_TOKEN_TTL_HOURS", ["orders", "tokenTtlHours"]),
|
||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
||||
stringEnv("KAQUAN91_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||
stringEnv("KAQUAN91_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||
stringEnv("KAQUAN91_VERSION", ["platforms", "ninetyone", "version"]),
|
||||
stringEnv("KAQUAN91_SHOP_ID", ["platforms", "ninetyone", "shopId"]),
|
||||
stringEnv("KAQUAN91_SHOP_NAME", ["platforms", "ninetyone", "shopName"]),
|
||||
integerEnv("KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS", [
|
||||
"platforms",
|
||||
"ninetyone",
|
||||
"timestampToleranceSeconds",
|
||||
integerEnv('PORT', ['server', 'port']),
|
||||
stringEnv('DATA_ROOT', ['data', 'root'], path.resolve),
|
||||
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
||||
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
||||
integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']),
|
||||
stringEnv('DATABASE_URL', ['database', 'url']),
|
||||
booleanEnv('DATABASE_SSL', ['database', 'ssl']),
|
||||
integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']),
|
||||
stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']),
|
||||
stringEnv('STORAGE_BUCKET', ['storage', 'bucket']),
|
||||
stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']),
|
||||
stringEnv('STORAGE_SECRET_ACCESS_KEY', ['storage', 'secretAccessKey']),
|
||||
stringEnv('STORAGE_REGION', ['storage', 'region']),
|
||||
integerEnv('STORAGE_MAX_UPLOAD_SIZE_MB', ['storage', 'maxUploadSizeMb']),
|
||||
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
|
||||
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
|
||||
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']),
|
||||
adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']),
|
||||
stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']),
|
||||
stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']),
|
||||
stringEnv('KAQUAN91_VERSION', ['platforms', 'ninetyone', 'version']),
|
||||
stringEnv('KAQUAN91_SHOP_ID', ['platforms', 'ninetyone', 'shopId']),
|
||||
stringEnv('KAQUAN91_SHOP_NAME', ['platforms', 'ninetyone', 'shopName']),
|
||||
integerEnv('KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS', [
|
||||
'platforms',
|
||||
'ninetyone',
|
||||
'timestampToleranceSeconds',
|
||||
]),
|
||||
stringEnv("KAQUAN91_CARDS_ENCODING", [
|
||||
"platforms",
|
||||
"ninetyone",
|
||||
"cardsEncoding",
|
||||
stringEnv('KAQUAN91_CARDS_ENCODING', ['platforms', 'ninetyone', 'cardsEncoding']),
|
||||
stringEnv('CLOUDTENTACLES_BASE_URL', ['platforms', 'cloudtentacles', 'baseUrl']),
|
||||
integerEnv('CLOUDTENTACLES_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'timeoutMs']),
|
||||
stringEnv('CLOUDTENTACLES_SEND_SMS_PATH', ['platforms', 'cloudtentacles', 'sendSmsPath']),
|
||||
stringEnv('CLOUDTENTACLES_LOGIN_PATH', ['platforms', 'cloudtentacles', 'loginPath']),
|
||||
stringEnv('CLOUDTENTACLES_USER_INFO_PATH', ['platforms', 'cloudtentacles', 'userInfoPath']),
|
||||
stringEnv('CLOUDTENTACLES_ASSET_PATH', ['platforms', 'cloudtentacles', 'assetPath']),
|
||||
stringEnv('CLOUDTENTACLES_PERMISSION_PATH', ['platforms', 'cloudtentacles', 'permissionPath']),
|
||||
stringEnv('CLOUDTENTACLES_CATEGORIES_PATH', ['platforms', 'cloudtentacles', 'categoriesPath']),
|
||||
stringEnv('CLOUDTENTACLES_SKU_LIST_PATH', ['platforms', 'cloudtentacles', 'skuListPath']),
|
||||
stringEnv('CLOUDTENTACLES_SKU_BUY_PATH', ['platforms', 'cloudtentacles', 'skuBuyPath']),
|
||||
stringEnv('CLOUDTENTACLES_KNAPSACK_PATH', ['platforms', 'cloudtentacles', 'knapsackPath']),
|
||||
stringEnv('CLOUDTENTACLES_VN_LIST_PATH', ['platforms', 'cloudtentacles', 'vnListPath']),
|
||||
stringEnv('CLOUDTENTACLES_VN_APPOINT_PATH', ['platforms', 'cloudtentacles', 'vnAppointPath']),
|
||||
stringEnv('CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'vnGenerateLoginCodePath',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BASE_URL", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"baseUrl",
|
||||
stringEnv('CLOUDTENTACLES_VN_VERIF_CODE_PATH', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'vnVerifCodePath',
|
||||
]),
|
||||
integerEnv("CLOUDTENTACLES_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"timeoutMs",
|
||||
stringEnv('CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'vnVerifyLoginCodePath',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_SEND_SMS_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"sendSmsPath",
|
||||
stringEnv('CLOUDTENTACLES_VN_BIND_URL_PATH', ['platforms', 'cloudtentacles', 'vnBindUrlPath']),
|
||||
stringEnv('CLOUDTENTACLES_VN_BIND_INFO_PATH', ['platforms', 'cloudtentacles', 'vnBindInfoPath']),
|
||||
stringEnv('CLOUDTENTACLES_VN_BACK_PATH', ['platforms', 'cloudtentacles', 'vnBackPath']),
|
||||
integerEnv('CLOUDTENTACLES_BIND_URL_TTL_SECONDS', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlTtlSeconds',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_LOGIN_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"loginPath",
|
||||
integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeIntervalSeconds',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_USER_INFO_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"userInfoPath",
|
||||
integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeTimeoutMs',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_ASSET_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"assetPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeUserAgent',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_PERMISSION_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"permissionPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeEndpoint',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_CATEGORIES_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"categoriesPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeChartId',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_SKU_LIST_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"skuListPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeSubChartId',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_SKU_BUY_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"skuBuyPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeIdeToken',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_KNAPSACK_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"knapsackPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeActivityUrl',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_LIST_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnListPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_REFERER', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeReferer',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_APPOINT_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnAppointPath",
|
||||
stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE', [
|
||||
'platforms',
|
||||
'cloudtentacles',
|
||||
'bindUrlProbeExtraCookie',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnGenerateLoginCodePath",
|
||||
stringEnv('CLOUDTENTACLES_PUBLIC_KEY_PEM', ['platforms', 'cloudtentacles', 'publicKeyPem']),
|
||||
stringEnv('CLOUDTENTACLES_CLIENT_SOURCE', ['platforms', 'cloudtentacles', 'clientSource']),
|
||||
stringEnv('CLOUDTENTACLES_DEVICE_ID', ['platforms', 'cloudtentacles', 'deviceId']),
|
||||
integerEnv('CLOUDTENTACLES_DEVICE_TYPE', ['platforms', 'cloudtentacles', 'deviceType']),
|
||||
stringEnv('KUASHOU_INDUSTRY_APP_KEY', ['platforms', 'kuaishouIndustry', 'appKey']),
|
||||
stringEnv('KUASHOU_INDUSTRY_APP_SECRET', ['platforms', 'kuaishouIndustry', 'appSecret']),
|
||||
stringEnv('KUASHOU_INDUSTRY_SIGN_SECRET', ['platforms', 'kuaishouIndustry', 'signSecret']),
|
||||
stringEnv('KUASHOU_INDUSTRY_MESSAGE_SECRET', ['platforms', 'kuaishouIndustry', 'messageSecret']),
|
||||
stringEnv('KUASHOU_INDUSTRY_PROVIDER', ['platforms', 'kuaishouIndustry', 'provider']),
|
||||
stringEnv('KUASHOU_INDUSTRY_SHOP_ID', ['platforms', 'kuaishouIndustry', 'shopId']),
|
||||
stringEnv('KUASHOU_INDUSTRY_SHOP_NAME', ['platforms', 'kuaishouIndustry', 'shopName']),
|
||||
stringEnv('KUASHOU_INDUSTRY_VERSION', ['platforms', 'kuaishouIndustry', 'version']),
|
||||
stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']),
|
||||
stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']),
|
||||
stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']),
|
||||
integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']),
|
||||
stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']),
|
||||
kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [
|
||||
'platforms',
|
||||
'kuaishouFeifei',
|
||||
'productRules',
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_VERIF_CODE_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnVerifCodePath",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnVerifyLoginCodePath",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_BIND_URL_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnBindUrlPath",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_BIND_INFO_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnBindInfoPath",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_VN_BACK_PATH", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"vnBackPath",
|
||||
]),
|
||||
integerEnv("CLOUDTENTACLES_BIND_URL_TTL_SECONDS", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlTtlSeconds",
|
||||
]),
|
||||
integerEnv("CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeIntervalSeconds",
|
||||
]),
|
||||
integerEnv("CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeTimeoutMs",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeUserAgent",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeEndpoint",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeChartId",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeSubChartId",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeIdeToken",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeActivityUrl",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_REFERER", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeReferer",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"bindUrlProbeExtraCookie",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_PUBLIC_KEY_PEM", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"publicKeyPem",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_CLIENT_SOURCE", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"clientSource",
|
||||
]),
|
||||
stringEnv("CLOUDTENTACLES_DEVICE_ID", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"deviceId",
|
||||
]),
|
||||
integerEnv("CLOUDTENTACLES_DEVICE_TYPE", [
|
||||
"platforms",
|
||||
"cloudtentacles",
|
||||
"deviceType",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_APP_KEY", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"appKey",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_APP_SECRET", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"appSecret",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_SIGN_SECRET", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"signSecret",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_MESSAGE_SECRET", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"messageSecret",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_PROVIDER", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"provider",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_SHOP_ID", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"shopId",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_SHOP_NAME", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"shopName",
|
||||
]),
|
||||
stringEnv("KUASHOU_INDUSTRY_VERSION", [
|
||||
"platforms",
|
||||
"kuaishouIndustry",
|
||||
"version",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_BASE_URL", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"baseUrl",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_APP_KEY", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"appKey",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"appSecret",
|
||||
]),
|
||||
integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"timeoutMs",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"notifyUrl",
|
||||
]),
|
||||
kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"productRules",
|
||||
]),
|
||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||
];
|
||||
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
||||
]
|
||||
|
||||
export function applyEnvOverrides(
|
||||
baseConfig: RuntimeConfig,
|
||||
env: RuntimeEnv = process.env
|
||||
env: RuntimeEnv = process.env,
|
||||
): RuntimeConfig {
|
||||
const nextConfig = deepMerge(baseConfig, {});
|
||||
const nextConfig = deepMerge(baseConfig, {})
|
||||
|
||||
applyClaimBaseUrlOverride(nextConfig, env);
|
||||
applyClaimBaseUrlOverride(nextConfig, env)
|
||||
|
||||
for (const override of ENV_OVERRIDES) {
|
||||
const value = override.read(env[override.env]);
|
||||
const value = override.read(env[override.env])
|
||||
if (value !== null) {
|
||||
setConfigValue(nextConfig, override.path, value);
|
||||
setConfigValue(nextConfig, override.path, value)
|
||||
}
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
function applyClaimBaseUrlOverride(
|
||||
config: RuntimeConfig,
|
||||
env: RuntimeEnv
|
||||
): void {
|
||||
const claimBaseUrl = parseString(env.CLAIM_BASE_URL);
|
||||
function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void {
|
||||
const claimBaseUrl = parseString(env.CLAIM_BASE_URL)
|
||||
if (claimBaseUrl !== null) {
|
||||
config.orders.claimBaseUrl = claimBaseUrl;
|
||||
return;
|
||||
config.orders.claimBaseUrl = claimBaseUrl
|
||||
return
|
||||
}
|
||||
|
||||
const appBaseUrl = parseString(env.APP_BASE_URL);
|
||||
const appBaseUrl = parseString(env.APP_BASE_URL)
|
||||
if (appBaseUrl !== null) {
|
||||
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, "")}/#/claim`;
|
||||
config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim`
|
||||
}
|
||||
}
|
||||
|
||||
function stringEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath,
|
||||
transform: (value: string) => string = (value) => value
|
||||
transform: (value: string) => string = (value) => value,
|
||||
): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
const value = parseString(rawValue);
|
||||
return value === null ? null : transform(value);
|
||||
const value = parseString(rawValue)
|
||||
return value === null ? null : transform(value)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
@@ -354,7 +217,7 @@ function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
env,
|
||||
path: configPath,
|
||||
read: parseInteger,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
@@ -362,90 +225,74 @@ function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
env,
|
||||
path: configPath,
|
||||
read: parseBoolean,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function adminUsersJsonEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
): EnvOverride {
|
||||
function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
return parseJsonArray<AdminDefaultUser>(rawValue);
|
||||
return parseJsonArray<AdminDefaultUser>(rawValue)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function kuaishouFeifeiProductRulesEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
): EnvOverride {
|
||||
function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
const parsed = parseJsonArray<KuaishouFeifeiProductRule>(rawValue);
|
||||
return parsed === null ? null : parsed;
|
||||
const parsed = parseJsonArray<KuaishouFeifeiProductRule>(rawValue)
|
||||
return parsed === null ? null : parsed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function corsOriginsEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
): EnvOverride {
|
||||
function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
const value = parseString(rawValue);
|
||||
const value = parseString(rawValue)
|
||||
if (value === null) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (value.trim() === "*") {
|
||||
return ["*"];
|
||||
if (value.trim() === '*') {
|
||||
return ['*']
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseString(rawValue: unknown): string | null {
|
||||
const value = String(rawValue || "").trim();
|
||||
return value ? value : null;
|
||||
const value = String(rawValue || '').trim()
|
||||
return value ? value : null
|
||||
}
|
||||
|
||||
function setConfigValue(
|
||||
config: RuntimeConfig,
|
||||
configPath: RuntimeConfigPath,
|
||||
value: RuntimeConfigValue
|
||||
value: RuntimeConfigValue,
|
||||
): void {
|
||||
let current: Record<string, unknown> = config as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
let current: Record<string, unknown> = config as unknown as Record<string, unknown>
|
||||
|
||||
for (const key of configPath.slice(0, -1)) {
|
||||
const nextValue = current[key];
|
||||
if (
|
||||
!nextValue ||
|
||||
typeof nextValue !== "object" ||
|
||||
Array.isArray(nextValue)
|
||||
) {
|
||||
current[key] = {};
|
||||
const nextValue = current[key]
|
||||
if (!nextValue || typeof nextValue !== 'object' || Array.isArray(nextValue)) {
|
||||
current[key] = {}
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
current = current[key] as Record<string, unknown>
|
||||
}
|
||||
|
||||
const lastKey = configPath[configPath.length - 1];
|
||||
const lastKey = configPath[configPath.length - 1]
|
||||
if (!lastKey) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
current[lastKey] = value
|
||||
}
|
||||
|
||||
@@ -10,54 +10,63 @@ import { deepMerge } from './runtime-merge.js'
|
||||
import type { RuntimeConfig } from '../types/runtime-config.js'
|
||||
|
||||
test('validateRuntimeConfig allows missing admin credentials outside production', () => {
|
||||
const issues = validateRuntimeConfig(createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: '',
|
||||
defaultUsers: [],
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: '',
|
||||
defaultUsers: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
env: { NODE_ENV: 'development' },
|
||||
},
|
||||
}), {
|
||||
env: { NODE_ENV: 'development' },
|
||||
})
|
||||
)
|
||||
|
||||
assert.deepEqual(issues, [])
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig requires admin credentials in production', () => {
|
||||
const issues = validateRuntimeConfig(createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: '',
|
||||
defaultUsers: [],
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: '',
|
||||
defaultUsers: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
env: { NODE_ENV: 'production' },
|
||||
},
|
||||
}), {
|
||||
env: { NODE_ENV: 'production' },
|
||||
})
|
||||
)
|
||||
|
||||
assert.deepEqual(issues.map((issue) => issue.path), [
|
||||
'admin.sessionSecret',
|
||||
'admin.defaultUsers',
|
||||
])
|
||||
assert.deepEqual(
|
||||
issues.map((issue) => issue.path),
|
||||
['admin.sessionSecret', 'admin.defaultUsers'],
|
||||
)
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig rejects unsafe production placeholders', () => {
|
||||
const issues = validateRuntimeConfig(createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: 'change-me-long-random-session-secret',
|
||||
defaultUsers: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'change-me-admin-password',
|
||||
role: 'admin',
|
||||
},
|
||||
],
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
admin: {
|
||||
sessionSecret: 'change-me-long-random-session-secret',
|
||||
defaultUsers: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'change-me-admin-password',
|
||||
role: 'admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
env: { NODE_ENV: 'production' },
|
||||
},
|
||||
}), {
|
||||
env: { NODE_ENV: 'production' },
|
||||
})
|
||||
)
|
||||
|
||||
assert.deepEqual(issues.map((issue) => issue.path), [
|
||||
'admin.sessionSecret',
|
||||
'admin.defaultUsers[0].password',
|
||||
])
|
||||
assert.deepEqual(
|
||||
issues.map((issue) => issue.path),
|
||||
['admin.sessionSecret', 'admin.defaultUsers[0].password'],
|
||||
)
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig accepts complete production config', () => {
|
||||
@@ -69,123 +78,140 @@ test('validateRuntimeConfig accepts complete production config', () => {
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
||||
const issues = validateRuntimeConfig(createRuntimeConfig({
|
||||
server: {
|
||||
port: 70000,
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
server: {
|
||||
port: 70000,
|
||||
},
|
||||
database: {
|
||||
maxConnections: 0,
|
||||
},
|
||||
orders: {
|
||||
claimBaseUrl: 'not-a-url',
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
}),
|
||||
{
|
||||
env: { NODE_ENV: 'development' },
|
||||
},
|
||||
database: {
|
||||
maxConnections: 0,
|
||||
},
|
||||
orders: {
|
||||
claimBaseUrl: 'not-a-url',
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
}), {
|
||||
env: { NODE_ENV: 'development' },
|
||||
})
|
||||
)
|
||||
|
||||
assert.deepEqual(issues.map((issue) => issue.path), [
|
||||
'server.port',
|
||||
'database.maxConnections',
|
||||
'orders.tokenTtlHours',
|
||||
'orders.claimBaseUrl',
|
||||
])
|
||||
assert.deepEqual(
|
||||
issues.map((issue) => issue.path),
|
||||
['server.port', 'database.maxConnections', 'orders.tokenTtlHours', 'orders.claimBaseUrl'],
|
||||
)
|
||||
})
|
||||
|
||||
test('assertRuntimeConfigValid throws a readable validation error', () => {
|
||||
assert.throws(() => {
|
||||
assertRuntimeConfigValid(createRuntimeConfig({
|
||||
server: {
|
||||
port: 0,
|
||||
},
|
||||
}))
|
||||
}, (error) => {
|
||||
assert.equal(error instanceof RuntimeConfigValidationError, true)
|
||||
assert.match((error as Error).message, /运行时配置校验失败/)
|
||||
assert.match((error as Error).message, /server\.port/)
|
||||
return true
|
||||
})
|
||||
assert.throws(
|
||||
() => {
|
||||
assertRuntimeConfigValid(
|
||||
createRuntimeConfig({
|
||||
server: {
|
||||
port: 0,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
(error) => {
|
||||
assert.equal(error instanceof RuntimeConfigValidationError, true)
|
||||
assert.match((error as Error).message, /运行时配置校验失败/)
|
||||
assert.match((error as Error).message, /server\.port/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeConfig {
|
||||
return deepMerge({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
integrationLevel: 'warn',
|
||||
},
|
||||
database: {
|
||||
url: 'postgres://postgres:postgres@postgres:5432/order_site',
|
||||
ssl: false,
|
||||
maxConnections: 10,
|
||||
},
|
||||
orders: {
|
||||
claimBaseUrl: 'https://order.example.test/#/claim',
|
||||
tokenTtlHours: 24,
|
||||
},
|
||||
admin: {
|
||||
sessionSecret: '0123456789abcdef0123456789abcdef',
|
||||
sessionTtlHours: 12,
|
||||
defaultUsers: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'Qp7xT2mN9vR4',
|
||||
role: 'admin',
|
||||
return deepMerge(
|
||||
{
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
integrationLevel: 'warn',
|
||||
},
|
||||
database: {
|
||||
url: 'postgres://postgres:postgres@postgres:5432/order_site',
|
||||
ssl: false,
|
||||
maxConnections: 10,
|
||||
},
|
||||
storage: {
|
||||
endpoint: 'http://minio:9000',
|
||||
bucket: 'order-site',
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
maxUploadSizeMb: 10,
|
||||
},
|
||||
orders: {
|
||||
claimBaseUrl: 'https://order.example.test/#/claim',
|
||||
tokenTtlHours: 24,
|
||||
},
|
||||
admin: {
|
||||
sessionSecret: '0123456789abcdef0123456789abcdef',
|
||||
sessionTtlHours: 12,
|
||||
defaultUsers: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'Qp7xT2mN9vR4',
|
||||
role: 'admin',
|
||||
},
|
||||
],
|
||||
},
|
||||
platforms: {
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
version: '1.0',
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
timestampToleranceSeconds: 600,
|
||||
cardsEncoding: 'aes-256-ecb-base64',
|
||||
},
|
||||
cloudtentacles: {
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
timeoutMs: 5000,
|
||||
sendSmsPath: '/public/verif_code',
|
||||
loginPath: '/public/login',
|
||||
userInfoPath: '/user/info',
|
||||
assetPath: '/user/get_asset',
|
||||
permissionPath: '/user/get_permission',
|
||||
categoriesPath: '/categories/get',
|
||||
skuListPath: '/sku/list',
|
||||
skuBuyPath: '/sku/buy',
|
||||
skuUsePath: '/sku/use',
|
||||
knapsackPath: '/user/get_knapsack',
|
||||
vnListPath: '/vn/list',
|
||||
vnAppointPath: '/vn/appoint',
|
||||
vnGenerateLoginCodePath: '/vn/generate_login_code',
|
||||
vnVerifCodePath: '/public/vn_verif_code',
|
||||
vnVerifyLoginCodePath: '/vn/verify_login_code',
|
||||
vnBindUrlPath: '/vn/bind_url',
|
||||
vnBindInfoPath: '/vn/bind_info',
|
||||
vnBackPath: '/vn/back',
|
||||
bindUrlTtlSeconds: 600,
|
||||
bindUrlProbeIntervalSeconds: 30,
|
||||
bindUrlProbeTimeoutMs: 5000,
|
||||
bindUrlProbeUserAgent: '',
|
||||
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
|
||||
bindUrlProbeChartId: '323794',
|
||||
bindUrlProbeSubChartId: '323794',
|
||||
bindUrlProbeIdeToken: 'z90Syo',
|
||||
bindUrlProbeActivityUrl: '',
|
||||
bindUrlProbeReferer: 'https://gp.qq.com/',
|
||||
bindUrlProbeExtraCookie: '',
|
||||
publicKeyPem: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||
deviceType: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
platforms: {
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
version: '1.0',
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
timestampToleranceSeconds: 600,
|
||||
cardsEncoding: 'aes-256-ecb-base64',
|
||||
},
|
||||
cloudtentacles: {
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
timeoutMs: 5000,
|
||||
sendSmsPath: '/public/verif_code',
|
||||
loginPath: '/public/login',
|
||||
userInfoPath: '/user/info',
|
||||
assetPath: '/user/get_asset',
|
||||
permissionPath: '/user/get_permission',
|
||||
categoriesPath: '/categories/get',
|
||||
skuListPath: '/sku/list',
|
||||
skuBuyPath: '/sku/buy',
|
||||
skuUsePath: '/sku/use',
|
||||
knapsackPath: '/user/get_knapsack',
|
||||
vnListPath: '/vn/list',
|
||||
vnAppointPath: '/vn/appoint',
|
||||
vnGenerateLoginCodePath: '/vn/generate_login_code',
|
||||
vnVerifCodePath: '/public/vn_verif_code',
|
||||
vnVerifyLoginCodePath: '/vn/verify_login_code',
|
||||
vnBindUrlPath: '/vn/bind_url',
|
||||
vnBindInfoPath: '/vn/bind_info',
|
||||
vnBackPath: '/vn/back',
|
||||
bindUrlTtlSeconds: 600,
|
||||
bindUrlProbeIntervalSeconds: 30,
|
||||
bindUrlProbeTimeoutMs: 5000,
|
||||
bindUrlProbeUserAgent: '',
|
||||
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
|
||||
bindUrlProbeChartId: '323794',
|
||||
bindUrlProbeSubChartId: '323794',
|
||||
bindUrlProbeIdeToken: 'z90Syo',
|
||||
bindUrlProbeActivityUrl: '',
|
||||
bindUrlProbeReferer: 'https://gp.qq.com/',
|
||||
bindUrlProbeExtraCookie: '',
|
||||
publicKeyPem: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||
deviceType: 1,
|
||||
},
|
||||
},
|
||||
}, overrides) as RuntimeConfig
|
||||
overrides,
|
||||
) as RuntimeConfig
|
||||
}
|
||||
|
||||
@@ -45,13 +45,32 @@ export function validateRuntimeConfig(
|
||||
|
||||
requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 })
|
||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
})
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||
requireInteger(
|
||||
issues,
|
||||
'platforms.cloudtentacles.timeoutMs',
|
||||
config.platforms?.cloudtentacles?.timeoutMs,
|
||||
{ min: 1 },
|
||||
)
|
||||
if (config.platforms?.kuaishouFeifei) {
|
||||
requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 })
|
||||
requireInteger(
|
||||
issues,
|
||||
'platforms.kuaishouFeifei.timeoutMs',
|
||||
config.platforms.kuaishouFeifei.timeoutMs,
|
||||
{ min: 1 },
|
||||
)
|
||||
}
|
||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||
requireInteger(
|
||||
issues,
|
||||
'platforms.cloudtentacles.bindUrlTtlSeconds',
|
||||
config.platforms?.cloudtentacles?.bindUrlTtlSeconds,
|
||||
{ min: 1 },
|
||||
)
|
||||
requireInteger(
|
||||
issues,
|
||||
'platforms.cloudtentacles.bindUrlProbeIntervalSeconds',
|
||||
@@ -66,10 +85,18 @@ export function validateRuntimeConfig(
|
||||
)
|
||||
|
||||
validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateRequiredString(issues, 'data.root', config.data?.root)
|
||||
|
||||
if (productionLike) {
|
||||
validateRequiredString(issues, 'database.url', config.database?.url)
|
||||
validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateRequiredString(issues, 'storage.bucket', config.storage?.bucket)
|
||||
validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId)
|
||||
validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, {
|
||||
minLength: 8,
|
||||
})
|
||||
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
||||
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
||||
@@ -96,7 +123,9 @@ function validateAdminDefaultUsers(
|
||||
|
||||
users.forEach((user, index) => {
|
||||
validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username)
|
||||
validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { minLength: 8 })
|
||||
validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, {
|
||||
minLength: 8,
|
||||
})
|
||||
validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role)
|
||||
})
|
||||
}
|
||||
@@ -171,11 +200,34 @@ function validateRequiredHttpUrl(
|
||||
validateOptionalHttpUrl(issues, configPath, value)
|
||||
}
|
||||
|
||||
function validateOptionalStorageEndpoint(
|
||||
issues: RuntimeConfigValidationIssue[],
|
||||
configPath: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(text)) {
|
||||
validateOptionalHttpUrl(issues, configPath, text)
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(text)) {
|
||||
issues.push({
|
||||
path: configPath,
|
||||
message: '必须是 http/https URL 或 host:port',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function requireInteger(
|
||||
issues: RuntimeConfigValidationIssue[],
|
||||
configPath: string,
|
||||
value: unknown,
|
||||
{ min, max }: { min?: number, max?: number } = {},
|
||||
{ min, max }: { min?: number; max?: number } = {},
|
||||
): void {
|
||||
const numberValue = Number(value)
|
||||
if (!Number.isInteger(numberValue)) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- 005_file_storage_and_worker_product_rules.sql —— 统一文件存储与接单物品规则。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_assets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
object_key TEXT NOT NULL UNIQUE,
|
||||
scene TEXT NOT NULL DEFAULT 'misc',
|
||||
original_filename TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
thumbnail_object_key TEXT NOT NULL DEFAULT '',
|
||||
medium_object_key TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
thumbnail_url TEXT NOT NULL DEFAULT '',
|
||||
medium_url TEXT NOT NULL DEFAULT '',
|
||||
uploader_type TEXT NOT NULL DEFAULT '',
|
||||
uploader_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_file_assets_scene_created
|
||||
ON file_assets(scene, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_assets_uploader_created
|
||||
ON file_assets(uploader_type, uploader_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_product_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rule_key TEXT NOT NULL UNIQUE,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
sku_code TEXT NOT NULL DEFAULT '',
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
match_type TEXT NOT NULL DEFAULT 'contains',
|
||||
category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
auto_create BOOLEAN NOT NULL DEFAULT false,
|
||||
reward_amount INTEGER NOT NULL DEFAULT 0,
|
||||
required_deposit_amount INTEGER NOT NULL DEFAULT 0,
|
||||
deposit_threshold_amount INTEGER NOT NULL DEFAULT 20000,
|
||||
requirement_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_product_rules_enabled_sort
|
||||
ON work_product_rules(enabled, sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_product_rules_source
|
||||
ON work_product_rules(provider, platform, shop_id, sku_code);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_orders_order_item_unique
|
||||
ON work_orders(order_item_id)
|
||||
WHERE order_item_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_orders_task_unique
|
||||
ON work_orders(task_id)
|
||||
WHERE task_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE file_assets IS '统一文件资产,底层使用 MinIO/S3 兼容对象存储';
|
||||
COMMENT ON COLUMN file_assets.object_key IS '对象存储 key';
|
||||
COMMENT ON COLUMN file_assets.thumbnail_object_key IS '缩略图对象 key';
|
||||
COMMENT ON COLUMN file_assets.medium_object_key IS '中图对象 key';
|
||||
COMMENT ON TABLE work_product_rules IS '接单平台物品规则:决定哪些源订单商品生成接单工单';
|
||||
COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给接单员的单笔金额,单位分';
|
||||
COMMENT ON COLUMN work_product_rules.auto_create IS '源订单入库后是否自动创建接单工单';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
export type FileAssetRow = {
|
||||
id: number
|
||||
object_key: string
|
||||
scene: string
|
||||
original_filename: string
|
||||
content_type: string
|
||||
size_bytes: number
|
||||
thumbnail_object_key: string
|
||||
medium_object_key: string
|
||||
url: string
|
||||
thumbnail_url: string
|
||||
medium_url: string
|
||||
uploader_type: string
|
||||
uploader_id: string
|
||||
status: string
|
||||
metadata_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type CreateFileAssetInput = {
|
||||
objectKey: string
|
||||
scene: string
|
||||
originalFilename: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
thumbnailObjectKey: string
|
||||
mediumObjectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
metadataJson: string
|
||||
now: string
|
||||
}
|
||||
|
||||
export async function createFileAsset(input: CreateFileAssetInput): Promise<FileAssetRow | null> {
|
||||
const result = await query<FileAssetRow>(
|
||||
`
|
||||
INSERT INTO file_assets (
|
||||
object_key, scene, original_filename, content_type, size_bytes,
|
||||
thumbnail_object_key, medium_object_key, url, thumbnail_url, medium_url,
|
||||
uploader_type, uploader_id, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, $10,
|
||||
$11, $12, $13::jsonb, $14, $15
|
||||
)
|
||||
ON CONFLICT (object_key) DO UPDATE
|
||||
SET
|
||||
scene = EXCLUDED.scene,
|
||||
original_filename = EXCLUDED.original_filename,
|
||||
content_type = EXCLUDED.content_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
thumbnail_object_key = EXCLUDED.thumbnail_object_key,
|
||||
medium_object_key = EXCLUDED.medium_object_key,
|
||||
url = EXCLUDED.url,
|
||||
thumbnail_url = EXCLUDED.thumbnail_url,
|
||||
medium_url = EXCLUDED.medium_url,
|
||||
uploader_type = EXCLUDED.uploader_type,
|
||||
uploader_id = EXCLUDED.uploader_id,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.objectKey,
|
||||
input.scene,
|
||||
input.originalFilename,
|
||||
input.contentType,
|
||||
input.sizeBytes,
|
||||
input.thumbnailObjectKey,
|
||||
input.mediumObjectKey,
|
||||
input.url,
|
||||
input.thumbnailUrl,
|
||||
input.mediumUrl,
|
||||
input.uploaderType,
|
||||
input.uploaderId,
|
||||
input.metadataJson,
|
||||
input.now,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getFileAssetByObjectKey(objectKey: string): Promise<FileAssetRow | null> {
|
||||
const result = await query<FileAssetRow>(
|
||||
'SELECT * FROM file_assets WHERE object_key = $1 AND status = $2 LIMIT 1',
|
||||
[String(objectKey || '').trim(), 'active'],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import type {
|
||||
OrderListQueryInput,
|
||||
OrderUpdateInput,
|
||||
} from '../types/repository/inputs.js'
|
||||
import type {
|
||||
OrderListQueryResult,
|
||||
OrderListRow,
|
||||
OrderRow,
|
||||
} from '../types/repository/rows.js'
|
||||
import type { OrderListQueryResult, OrderListRow, OrderRow } from '../types/repository/rows.js'
|
||||
|
||||
type OrderPlatformLookupInput = {
|
||||
provider?: string
|
||||
@@ -55,6 +51,23 @@ export async function findLatestOrderByPlatformOrderId({
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findLatestOrderByAnyPlatformOrderId(
|
||||
platformOrderId: string,
|
||||
): Promise<OrderRow | null> {
|
||||
const result = await query<OrderRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE platform_order_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[String(platformOrderId || '').trim()],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function createOrder(input: OrderCreateInput): Promise<OrderRow | null> {
|
||||
const result = await query<OrderRow>(
|
||||
`
|
||||
@@ -101,7 +114,10 @@ export async function createOrder(input: OrderCreateInput): Promise<OrderRow | n
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function updateOrder(orderId: number | string, input: OrderUpdateInput): Promise<OrderRow | null> {
|
||||
export async function updateOrder(
|
||||
orderId: number | string,
|
||||
input: OrderUpdateInput,
|
||||
): Promise<OrderRow | null> {
|
||||
const result = await query<OrderRow>(
|
||||
`
|
||||
UPDATE orders
|
||||
@@ -146,7 +162,9 @@ export async function updateOrder(orderId: number | string, input: OrderUpdateIn
|
||||
}
|
||||
|
||||
export async function getOrderById(orderId: number | string): Promise<OrderRow | null> {
|
||||
const result = await query<OrderRow>('SELECT * FROM orders WHERE id = $1 LIMIT 1', [Number(orderId)])
|
||||
const result = await query<OrderRow>('SELECT * FROM orders WHERE id = $1 LIMIT 1', [
|
||||
Number(orderId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
@@ -175,7 +193,9 @@ export async function listOrders({
|
||||
|
||||
if (skuCode) {
|
||||
params.push(skuCode)
|
||||
filters.push(`EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`)
|
||||
filters.push(
|
||||
`EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`,
|
||||
)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
@@ -189,7 +209,7 @@ export async function listOrders({
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
||||
const totalResult = await query<{ [column: string]: unknown; total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,28 @@ export type WorkCategoryRow = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type WorkProductRuleRow = {
|
||||
id: number
|
||||
rule_key: string
|
||||
provider: string
|
||||
platform: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
product_name: string
|
||||
match_type: string
|
||||
category_id: number | null
|
||||
enabled: boolean
|
||||
auto_create: boolean
|
||||
reward_amount: number
|
||||
required_deposit_amount: number
|
||||
deposit_threshold_amount: number
|
||||
requirement_json: string | Record<string, unknown>
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
category_name?: string
|
||||
}
|
||||
|
||||
export type WorkOrderRow = {
|
||||
id: number
|
||||
work_order_no: string
|
||||
@@ -90,6 +112,11 @@ type ListInput = {
|
||||
workerId?: number
|
||||
}
|
||||
|
||||
type ProductRuleListInput = {
|
||||
enabled?: boolean | null
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
type CreateWorkerInput = {
|
||||
username: string
|
||||
passwordHash: string
|
||||
@@ -141,6 +168,14 @@ const WORK_ORDER_SELECT = `
|
||||
LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id
|
||||
`
|
||||
|
||||
const WORK_PRODUCT_RULE_SELECT = `
|
||||
SELECT
|
||||
wpr.*,
|
||||
wc.name AS category_name
|
||||
FROM work_product_rules wpr
|
||||
LEFT JOIN work_categories wc ON wc.id = wpr.category_id
|
||||
`
|
||||
|
||||
export async function getWorkerLevelByKey(levelKey: string): Promise<WorkerLevelRow | null> {
|
||||
const result = await query<WorkerLevelRow>(
|
||||
'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1',
|
||||
@@ -150,7 +185,9 @@ export async function getWorkerLevelByKey(levelKey: string): Promise<WorkerLevel
|
||||
}
|
||||
|
||||
export async function getWorkerLevelById(levelId: number | string): Promise<WorkerLevelRow | null> {
|
||||
const result = await query<WorkerLevelRow>('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [Number(levelId)])
|
||||
const result = await query<WorkerLevelRow>('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [
|
||||
Number(levelId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
@@ -199,16 +236,19 @@ export async function upsertWorkerLevel(input: {
|
||||
export async function getWorkerUserByUsername(username: string): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`,
|
||||
[String(username || '').trim().toLowerCase()],
|
||||
[
|
||||
String(username || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkerUserById(workerId: number | string): Promise<WorkerUserRow | null> {
|
||||
const result = await query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`,
|
||||
[Number(workerId)],
|
||||
)
|
||||
const result = await query<WorkerUserRow>(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [
|
||||
Number(workerId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
@@ -241,7 +281,12 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
||||
|
||||
export async function updateWorkerUser(
|
||||
workerId: number | string,
|
||||
patch: Partial<Pick<WorkerUserRow, 'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at'>>,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
WorkerUserRow,
|
||||
'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at'
|
||||
>
|
||||
>,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
const current = await getWorkerUserById(workerId)
|
||||
if (!current) return null
|
||||
@@ -296,7 +341,10 @@ export async function listWorkerUsers({
|
||||
return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) }
|
||||
}
|
||||
|
||||
export async function ensureWorkerWallet(workerId: number | string, now: string): Promise<WorkerWalletRow | null> {
|
||||
export async function ensureWorkerWallet(
|
||||
workerId: number | string,
|
||||
now: string,
|
||||
): Promise<WorkerWalletRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await ensureWorkerWalletWithClient(client, Number(workerId), now)
|
||||
return getWorkerWalletWithClient(client, Number(workerId))
|
||||
@@ -341,7 +389,15 @@ export async function addWorkerWalletCredit(input: {
|
||||
audit_status, note, payload_json, created_at
|
||||
) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7)
|
||||
`,
|
||||
[input.workerId, input.amount, nextAvailable, nextFrozen, input.note, input.payloadJson, input.now],
|
||||
[
|
||||
input.workerId,
|
||||
input.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.note,
|
||||
input.payloadJson,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return getWorkerWalletWithClient(client, input.workerId)
|
||||
})
|
||||
@@ -362,6 +418,13 @@ export async function listWorkCategories(): Promise<WorkCategoryRow[]> {
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listAllWorkCategories(): Promise<WorkCategoryRow[]> {
|
||||
const result = await query<WorkCategoryRow>(
|
||||
`SELECT * FROM work_categories ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function upsertWorkCategory(input: {
|
||||
categoryKey: string
|
||||
name: string
|
||||
@@ -382,6 +445,101 @@ export async function upsertWorkCategory(input: {
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWorkProductRules({
|
||||
enabled = null,
|
||||
keyword = '',
|
||||
}: ProductRuleListInput = {}): Promise<WorkProductRuleRow[]> {
|
||||
const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword })
|
||||
const result = await query<WorkProductRuleRow>(
|
||||
`${WORK_PRODUCT_RULE_SELECT}
|
||||
${whereClause}
|
||||
ORDER BY wpr.sort_order ASC, wpr.id DESC`,
|
||||
params,
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function getWorkProductRuleByKey(ruleKey: string): Promise<WorkProductRuleRow | null> {
|
||||
const result = await query<WorkProductRuleRow>(
|
||||
`${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`,
|
||||
[String(ruleKey || '').trim()],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function upsertWorkProductRule(input: {
|
||||
ruleKey: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
productName: string
|
||||
matchType: string
|
||||
categoryId: number | null
|
||||
enabled: boolean
|
||||
autoCreate: boolean
|
||||
rewardAmount: number
|
||||
requiredDepositAmount: number
|
||||
depositThresholdAmount: number
|
||||
requirementJson: string
|
||||
sortOrder: number
|
||||
now: string
|
||||
}): Promise<WorkProductRuleRow | null> {
|
||||
const result = await query<WorkProductRuleRow>(
|
||||
`
|
||||
INSERT INTO work_product_rules (
|
||||
rule_key, provider, platform, shop_id, sku_code, product_name,
|
||||
match_type, category_id, enabled, auto_create, reward_amount,
|
||||
required_deposit_amount, deposit_threshold_amount, requirement_json,
|
||||
sort_order, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14::jsonb,
|
||||
$15, $16, $17
|
||||
)
|
||||
ON CONFLICT (rule_key) DO UPDATE
|
||||
SET
|
||||
provider = EXCLUDED.provider,
|
||||
platform = EXCLUDED.platform,
|
||||
shop_id = EXCLUDED.shop_id,
|
||||
sku_code = EXCLUDED.sku_code,
|
||||
product_name = EXCLUDED.product_name,
|
||||
match_type = EXCLUDED.match_type,
|
||||
category_id = EXCLUDED.category_id,
|
||||
enabled = EXCLUDED.enabled,
|
||||
auto_create = EXCLUDED.auto_create,
|
||||
reward_amount = EXCLUDED.reward_amount,
|
||||
required_deposit_amount = EXCLUDED.required_deposit_amount,
|
||||
deposit_threshold_amount = EXCLUDED.deposit_threshold_amount,
|
||||
requirement_json = EXCLUDED.requirement_json,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.ruleKey,
|
||||
input.provider,
|
||||
input.platform,
|
||||
input.shopId,
|
||||
input.skuCode,
|
||||
input.productName,
|
||||
input.matchType,
|
||||
input.categoryId,
|
||||
input.enabled,
|
||||
input.autoCreate,
|
||||
input.rewardAmount,
|
||||
input.requiredDepositAmount,
|
||||
input.depositThresholdAmount,
|
||||
input.requirementJson,
|
||||
input.sortOrder,
|
||||
input.now,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey)
|
||||
}
|
||||
|
||||
export async function createWorkOrder(input: CreateWorkOrderInput): Promise<WorkOrderRow | null> {
|
||||
const result = await query<{ id: number }>(
|
||||
`
|
||||
@@ -416,14 +574,23 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
|
||||
return getWorkOrderById(result.rows[0]?.id || 0)
|
||||
}
|
||||
|
||||
export async function getWorkOrderById(workOrderId: number | string): Promise<WorkOrderRow | null> {
|
||||
export async function getWorkOrderByOrderItemId(
|
||||
orderItemId: number | string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const result = await query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`,
|
||||
[Number(workOrderId)],
|
||||
`${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`,
|
||||
[Number(orderItemId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWorkOrderById(workOrderId: number | string): Promise<WorkOrderRow | null> {
|
||||
const result = await query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [
|
||||
Number(workOrderId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findPendingMaterialWorkOrderByPlatformOrderId(
|
||||
platformOrderId: string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
@@ -467,19 +634,21 @@ export async function listWorkOrders({
|
||||
|
||||
export async function updateWorkOrder(
|
||||
workOrderId: number | string,
|
||||
patch: Partial<Pick<
|
||||
WorkOrderRow,
|
||||
| 'status'
|
||||
| 'assigned_worker_id'
|
||||
| 'material_json'
|
||||
| 'acceptance_json'
|
||||
| 'problem_note'
|
||||
| 'published_at'
|
||||
| 'assigned_at'
|
||||
| 'submitted_at'
|
||||
| 'accepted_at'
|
||||
| 'updated_at'
|
||||
>>,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
WorkOrderRow,
|
||||
| 'status'
|
||||
| 'assigned_worker_id'
|
||||
| 'material_json'
|
||||
| 'acceptance_json'
|
||||
| 'problem_note'
|
||||
| 'published_at'
|
||||
| 'assigned_at'
|
||||
| 'submitted_at'
|
||||
| 'accepted_at'
|
||||
| 'updated_at'
|
||||
>
|
||||
>,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const current = await getWorkOrderById(workOrderId)
|
||||
if (!current) return null
|
||||
@@ -568,7 +737,14 @@ export async function grabWorkOrder(input: {
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, '抢单冻结押金', '{}'::jsonb, $6)
|
||||
`,
|
||||
[input.workerId, -input.depositAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
|
||||
[
|
||||
input.workerId,
|
||||
-input.depositAmount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -756,7 +932,11 @@ async function createWorkOrderEventWithClient(
|
||||
)
|
||||
}
|
||||
|
||||
async function ensureWorkerWalletWithClient(client: PoolClient, workerId: number, now: string): Promise<void> {
|
||||
async function ensureWorkerWalletWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number,
|
||||
now: string,
|
||||
): Promise<void> {
|
||||
if (!workerId) return
|
||||
await client.query(
|
||||
`
|
||||
@@ -770,7 +950,10 @@ async function ensureWorkerWalletWithClient(client: PoolClient, workerId: number
|
||||
)
|
||||
}
|
||||
|
||||
async function getWorkerWalletWithClient(client: PoolClient, workerId: number): Promise<WorkerWalletRow | null> {
|
||||
async function getWorkerWalletWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number,
|
||||
): Promise<WorkerWalletRow | null> {
|
||||
const result = await client.query<WorkerWalletRow>(
|
||||
'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1',
|
||||
[workerId],
|
||||
@@ -778,7 +961,10 @@ async function getWorkerWalletWithClient(client: PoolClient, workerId: number):
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function getWorkerUserByIdWithClient(client: PoolClient, workerId: number | string): Promise<WorkerUserRow | null> {
|
||||
async function getWorkerUserByIdWithClient(
|
||||
client: PoolClient,
|
||||
workerId: number | string,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
const result = await client.query<WorkerUserRow>(
|
||||
`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`,
|
||||
[Number(workerId)],
|
||||
@@ -786,15 +972,20 @@ async function getWorkerUserByIdWithClient(client: PoolClient, workerId: number
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function getWorkOrderByIdWithClient(client: PoolClient, workOrderId: number | string): Promise<WorkOrderRow | null> {
|
||||
const result = await client.query<WorkOrderRow>(
|
||||
`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`,
|
||||
[Number(workOrderId)],
|
||||
)
|
||||
async function getWorkOrderByIdWithClient(
|
||||
client: PoolClient,
|
||||
workOrderId: number | string,
|
||||
): Promise<WorkOrderRow | null> {
|
||||
const result = await client.query<WorkOrderRow>(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [
|
||||
Number(workOrderId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
function buildWorkerUserWhere({ status = '', keyword = '' }: Pick<ListInput, 'status' | 'keyword'>) {
|
||||
function buildWorkerUserWhere({
|
||||
status = '',
|
||||
keyword = '',
|
||||
}: Pick<ListInput, 'status' | 'keyword'>) {
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
if (status) {
|
||||
@@ -803,7 +994,9 @@ function buildWorkerUserWhere({ status = '', keyword = '' }: Pick<ListInput, 'st
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`)
|
||||
filters.push(
|
||||
`(wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length} OR wu.phone ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
@@ -820,7 +1013,9 @@ function buildWorkOrderWhere({ status = '', keyword = '', workerId = 0 }: ListIn
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`(wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wo.product_name ILIKE $${params.length})`)
|
||||
filters.push(
|
||||
`(wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wo.product_name ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
if (workerId) {
|
||||
params.push(workerId)
|
||||
@@ -832,6 +1027,27 @@ function buildWorkOrderWhere({ status = '', keyword = '', workerId = 0 }: ListIn
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRuleListInput) {
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
if (enabled !== null && enabled !== undefined) {
|
||||
params.push(Boolean(enabled))
|
||||
filters.push(`wpr.enabled = $${params.length}`)
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`(
|
||||
wpr.rule_key ILIKE $${params.length}
|
||||
OR wpr.sku_code ILIKE $${params.length}
|
||||
OR wpr.product_name ILIKE $${params.length}
|
||||
)`)
|
||||
}
|
||||
return {
|
||||
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
function toJsonString(value: unknown): string {
|
||||
if (typeof value === 'string') return value || '{}'
|
||||
return JSON.stringify(value || {})
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import { Router } from "express";
|
||||
import { Router } from 'express'
|
||||
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import devMockRouter from "./admin/dev-mock.js";
|
||||
import kuaishouIndustryRouter from "./admin/kuaishou-industry.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
import { requireAdminSession } from "./admin/session.js";
|
||||
import tasksRouter from "./admin/tasks.js";
|
||||
import usersRouter from "./admin/users.js";
|
||||
import workerPlatformRouter from "./admin/worker-platform.js";
|
||||
import { buildNotFoundPayload } from "../utils/http.js";
|
||||
import authRouter from './admin/auth.js'
|
||||
import auditLogsRouter from './admin/audit-logs.js'
|
||||
import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import devMockRouter from './admin/dev-mock.js'
|
||||
import filesRouter from './admin/files.js'
|
||||
import kuaishouIndustryRouter from './admin/kuaishou-industry.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
import { requireAdminSession } from './admin/session.js'
|
||||
import tasksRouter from './admin/tasks.js'
|
||||
import usersRouter from './admin/users.js'
|
||||
import workerPlatformRouter from './admin/worker-platform.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(kuaishouIndustryRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(cloudtentaclesRecordsRouter);
|
||||
router.use(workerPlatformRouter);
|
||||
router.use(devMockRouter);
|
||||
router.use(authRouter)
|
||||
router.use(requireAdminSession)
|
||||
router.use(filesRouter)
|
||||
router.use(dashboardRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(kuaishouIndustryRouter)
|
||||
router.use(ordersRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(cloudtentaclesRecordsRouter)
|
||||
router.use(workerPlatformRouter)
|
||||
router.use(devMockRouter)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { uploadSingleFile } from '../file-upload.js'
|
||||
import { uploadFileAsset } from '../../services/file-storage/file-storage-service.js'
|
||||
import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post(
|
||||
'/files/upload',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
uploadSingleFile,
|
||||
createJsonHandler(
|
||||
(req) => {
|
||||
const session = getRequiredAdminSession(req)
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene,
|
||||
uploaderType: 'admin',
|
||||
uploaderId: String(session.userId || session.username || ''),
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[admin/files/upload]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'file_uploaded',
|
||||
targetType: 'file',
|
||||
targetId: String((data as { file?: { objectKey?: string } })?.file?.objectKey || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -5,13 +5,19 @@ import {
|
||||
creditAdminWorkerWallet,
|
||||
createAdminMockWorkOrder,
|
||||
getAdminWorkerPlatformSummary,
|
||||
listAdminWorkCategories,
|
||||
listAdminWorkProductRules,
|
||||
listAdminWorkerLevels,
|
||||
listAdminWorkerUsers,
|
||||
listAdminWorkOrders,
|
||||
markAdminWorkOrderProblem,
|
||||
publishAdminWorkOrder,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkerLevel,
|
||||
submitAdminWorkOrderMaterial,
|
||||
syncAdminWorkerOrdersFromSource,
|
||||
} from '../../services/worker-platform/worker-platform-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
|
||||
@@ -47,7 +53,61 @@ router.post(
|
||||
action: 'worker_level_saved',
|
||||
targetType: 'worker_level',
|
||||
targetId: String((data as { level?: { levelKey?: string } })?.level?.levelKey || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/categories',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminWorkCategories(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取接单分类失败',
|
||||
scope: '[admin/worker-platform/categories]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/categories',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => saveAdminWorkCategory(req.body || {}), {
|
||||
successMessage: '接单分类已保存',
|
||||
errorMessage: '保存接单分类失败',
|
||||
scope: '[admin/worker-platform/categories]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'work_category_saved',
|
||||
targetType: 'work_category',
|
||||
targetId: String(
|
||||
(data as { category?: { categoryKey?: string } })?.category?.categoryKey || '',
|
||||
),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/product-rules',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => listAdminWorkProductRules(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取接单物品规则失败',
|
||||
scope: '[admin/worker-platform/product-rules]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/product-rules',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => saveAdminWorkProductRule(req.body || {}), {
|
||||
successMessage: '接单物品规则已保存',
|
||||
errorMessage: '保存接单物品规则失败',
|
||||
scope: '[admin/worker-platform/product-rules]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'work_product_rule_saved',
|
||||
targetType: 'work_product_rule',
|
||||
targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -65,33 +125,39 @@ router.get(
|
||||
router.post(
|
||||
'/worker-platform/workers/:workerId/review',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}), {
|
||||
successMessage: '接单员审核状态已更新',
|
||||
errorMessage: '审核接单员失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/review]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_reviewed',
|
||||
targetType: 'worker',
|
||||
targetId: String(req.params.workerId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
createJsonHandler(
|
||||
(req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '接单员审核状态已更新',
|
||||
errorMessage: '审核接单员失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/review]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_reviewed',
|
||||
targetType: 'worker',
|
||||
targetId: String(req.params.workerId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/workers/:workerId/wallet-credit',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}), {
|
||||
successMessage: '接单员余额已增加',
|
||||
errorMessage: '人工充值失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/wallet-credit]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_wallet_credited',
|
||||
targetType: 'worker',
|
||||
targetId: String(req.params.workerId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
createJsonHandler(
|
||||
(req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '接单员余额已增加',
|
||||
errorMessage: '人工充值失败',
|
||||
scope: '[admin/worker-platform/workers/:workerId/wallet-credit]',
|
||||
audit: (req, data) => ({
|
||||
action: 'worker_wallet_credited',
|
||||
targetType: 'worker',
|
||||
targetId: String(req.params.workerId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
@@ -115,11 +181,46 @@ router.post(
|
||||
action: 'work_order_mock_created',
|
||||
targetType: 'work_order',
|
||||
targetId: String((data as { order?: { workOrderId?: number } })?.order?.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/source-orders/sync',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler((req) => syncAdminWorkerOrdersFromSource(req.body || {}), {
|
||||
successMessage: '源订单同步完成',
|
||||
errorMessage: '同步源订单失败',
|
||||
scope: '[admin/worker-platform/source-orders/sync]',
|
||||
audit: (_req, data) => ({
|
||||
action: 'work_order_source_synced',
|
||||
targetType: 'source_order',
|
||||
targetId: '',
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/material',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) => submitAdminWorkOrderMaterial(String(req.params.workOrderId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '订单资料已保存',
|
||||
errorMessage: '补充订单资料失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/material]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_material_saved',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/publish',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
@@ -131,7 +232,7 @@ router.post(
|
||||
action: 'work_order_published',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -139,17 +240,20 @@ router.post(
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/problem',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}), {
|
||||
successMessage: '订单已标记为问题单',
|
||||
errorMessage: '标记问题单失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/problem]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_marked_problem',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
}),
|
||||
}),
|
||||
createJsonHandler(
|
||||
(req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}),
|
||||
{
|
||||
successMessage: '订单已标记为问题单',
|
||||
errorMessage: '标记问题单失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/problem]',
|
||||
audit: (req, data) => ({
|
||||
action: 'work_order_marked_problem',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
@@ -163,7 +267,7 @@ router.post(
|
||||
action: 'work_order_accepted',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? data as Record<string, unknown> : {},
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import multer from 'multer'
|
||||
|
||||
import { getMaxUploadSizeBytes } from '../services/file-storage/file-storage-service.js'
|
||||
import { createHttpError, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: getMaxUploadSizeBytes(),
|
||||
files: 1,
|
||||
},
|
||||
})
|
||||
|
||||
export function uploadSingleFile(req: Request, res: Response, next: NextFunction): void {
|
||||
upload.single('file')(req, res, (error: unknown) => {
|
||||
if (!error) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedError =
|
||||
error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
|
||||
? createHttpError('文件超过上传大小限制', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
cause: error,
|
||||
})
|
||||
: createHttpError('文件上传失败', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_invalid',
|
||||
cause: error,
|
||||
})
|
||||
sendRouteError(res, normalizedError, '文件上传失败', '[files/upload]')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getStoredFileObject } from '../services/file-storage/file-storage-service.js'
|
||||
import { buildNotFoundPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/object', async (req, res) => {
|
||||
try {
|
||||
const object = await getStoredFileObject(req.query.key, req.query.variant)
|
||||
res.setHeader('Content-Type', object.contentType || 'application/octet-stream')
|
||||
res.setHeader('Cache-Control', 'private, max-age=300')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
object.reader.on('error', (error) => {
|
||||
if (!res.headersSent) {
|
||||
sendRouteError(res, error, '读取文件失败', '[files/object]')
|
||||
} else {
|
||||
res.destroy(error)
|
||||
}
|
||||
})
|
||||
object.reader.pipe(res)
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '读取文件失败', '[files/object]')
|
||||
}
|
||||
})
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import { uploadSingleFile } from './file-upload.js'
|
||||
import { uploadFileAsset } from '../services/file-storage/file-storage-service.js'
|
||||
import {
|
||||
getWorkerProfile,
|
||||
getWorkerSessionSummary,
|
||||
@@ -80,6 +82,28 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/files/upload',
|
||||
requireActiveWorker,
|
||||
uploadSingleFile,
|
||||
createRouteHandler(
|
||||
(req) => {
|
||||
const session = getRequiredWorkerSession(req)
|
||||
return uploadFileAsset({
|
||||
file: req.file,
|
||||
scene: req.body?.scene || 'worker-acceptance',
|
||||
uploaderType: 'worker',
|
||||
uploaderId: String(session.workerId),
|
||||
})
|
||||
},
|
||||
{
|
||||
successMessage: '文件已上传',
|
||||
errorMessage: '文件上传失败',
|
||||
scope: '[worker/files/upload]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/hall/orders',
|
||||
requireActiveWorker,
|
||||
@@ -93,11 +117,15 @@ router.get(
|
||||
router.post(
|
||||
'/hall/orders/:workOrderId/grab',
|
||||
requireActiveWorker,
|
||||
createRouteHandler((req) => grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), {
|
||||
successMessage: '抢单成功',
|
||||
errorMessage: '抢单失败',
|
||||
scope: '[worker/hall/orders/:workOrderId/grab]',
|
||||
}),
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)),
|
||||
{
|
||||
successMessage: '抢单成功',
|
||||
errorMessage: '抢单失败',
|
||||
scope: '[worker/hall/orders/:workOrderId/grab]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
@@ -114,7 +142,12 @@ router.post(
|
||||
'/orders/:workOrderId/submit-acceptance',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) => submitWorkerOrderAcceptance(String(req.params.workOrderId || ''), req.body || {}, getRequiredWorkerSession(req)),
|
||||
(req) =>
|
||||
submitWorkerOrderAcceptance(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
getRequiredWorkerSession(req),
|
||||
),
|
||||
{
|
||||
successMessage: '验收资料已提交',
|
||||
errorMessage: '提交验收资料失败',
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createFileAsset, type FileAssetRow } from '../../repositories/file-asset-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { getObjectStorage, normalizeObjectKey } from './object-storage.js'
|
||||
|
||||
const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
export type UploadedFileDto = {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
type UploadFileAssetInput = {
|
||||
file: Express.Multer.File | null | undefined
|
||||
scene: unknown
|
||||
uploaderType: string
|
||||
uploaderId: string
|
||||
}
|
||||
|
||||
export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
const file = input.file
|
||||
if (!file?.buffer?.length) {
|
||||
throw createHttpError('请选择要上传的文件', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_required',
|
||||
})
|
||||
}
|
||||
|
||||
const maxUploadSize = getMaxUploadSizeBytes()
|
||||
if (file.buffer.length > maxUploadSize) {
|
||||
throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_too_large',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = normalizeContentType(file.mimetype, file.buffer)
|
||||
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
||||
throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', {
|
||||
statusCode: 400,
|
||||
errorCode: 'upload_file_type_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const scene = normalizeScene(input.scene)
|
||||
const objectKey = createObjectKey(scene, file.originalname, contentType)
|
||||
const storage = getObjectStorage()
|
||||
await storage.putObject({
|
||||
key: objectKey,
|
||||
content: file.buffer,
|
||||
contentType,
|
||||
metadata: {
|
||||
'original-filename': file.originalname,
|
||||
},
|
||||
})
|
||||
|
||||
const variants = await generateImageVariants(objectKey, file.buffer, contentType)
|
||||
let thumbnailObjectKey = ''
|
||||
let mediumObjectKey = ''
|
||||
|
||||
for (const variant of variants) {
|
||||
try {
|
||||
await storage.putObject({
|
||||
key: variant.key,
|
||||
content: variant.content,
|
||||
contentType: variant.contentType,
|
||||
metadata: {
|
||||
'source-object': objectKey,
|
||||
},
|
||||
})
|
||||
if (variant.name === IMAGE_VARIANT_THUMB) {
|
||||
thumbnailObjectKey = variant.key
|
||||
}
|
||||
if (variant.name === IMAGE_VARIANT_MEDIUM) {
|
||||
mediumObjectKey = variant.key
|
||||
}
|
||||
} catch {
|
||||
// 缩略图失败不阻断原图上传;预览接口会回退到原图。
|
||||
}
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
originalFilename: file.originalname,
|
||||
contentType,
|
||||
sizeBytes: file.buffer.length,
|
||||
thumbnailObjectKey,
|
||||
mediumObjectKey,
|
||||
url: fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
uploaderType: String(input.uploaderType || '').trim(),
|
||||
uploaderId: String(input.uploaderId || '').trim(),
|
||||
metadataJson: JSON.stringify({
|
||||
originalMimeType: file.mimetype,
|
||||
originalSize: file.size,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
file: mapUploadedFile(asset, {
|
||||
objectKey,
|
||||
fileUrl,
|
||||
thumbnailUrl,
|
||||
mediumUrl,
|
||||
filename: file.originalname,
|
||||
contentType,
|
||||
size: file.buffer.length,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await getObjectStorage().getObject(candidate)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('文件不存在或暂不可访问', {
|
||||
statusCode: 404,
|
||||
errorCode: 'file_not_found',
|
||||
cause: lastError,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
const ext = path.extname(objectKey)
|
||||
const base = ext ? objectKey.slice(0, -ext.length) : objectKey
|
||||
return `${base}.${variant}.jpg`
|
||||
}
|
||||
|
||||
function mapUploadedFile(
|
||||
asset: FileAssetRow | null,
|
||||
fallback: {
|
||||
objectKey: string
|
||||
fileUrl: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
},
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
return detected
|
||||
}
|
||||
|
||||
return (
|
||||
String(mimeType || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(';')[0] || 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
function detectContentType(data: Buffer) {
|
||||
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
data.length >= 8 &&
|
||||
data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (
|
||||
data.length >= 12 &&
|
||||
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') {
|
||||
return 'application/pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeScene(value: unknown) {
|
||||
const scene = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return scene || 'misc'
|
||||
}
|
||||
|
||||
function createObjectKey(scene: string, filename: string, contentType: string) {
|
||||
const now = new Date()
|
||||
const token = crypto.randomBytes(12).toString('hex')
|
||||
const ext = extensionForContentType(contentType) || path.extname(filename).toLowerCase() || '.bin'
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${scene}/${now.getFullYear()}/${month}/${day}/${token}${ext}`
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
if (contentType === 'image/jpeg') return '.jpg'
|
||||
if (contentType === 'image/png') return '.png'
|
||||
if (contentType === 'image/webp') return '.webp'
|
||||
if (contentType === 'application/pdf') return '.pdf'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function generateImageVariants(objectKey: string, data: Buffer, contentType: string) {
|
||||
if (!IMAGE_CONTENT_TYPES.has(contentType)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const configs = [
|
||||
{ name: IMAGE_VARIANT_THUMB, maxSide: THUMB_MAX_SIDE, quality: 76 },
|
||||
{ name: IMAGE_VARIANT_MEDIUM, maxSide: MEDIUM_MAX_SIDE, quality: 82 },
|
||||
]
|
||||
const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = []
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const content = await sharp(data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: config.maxSide,
|
||||
height: config.maxSide,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.flatten({ background: '#ffffff' })
|
||||
.jpeg({ quality: config.quality, mozjpeg: true })
|
||||
.toBuffer()
|
||||
variants.push({
|
||||
name: config.name,
|
||||
key: imageVariantKey(objectKey, config.name),
|
||||
content,
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
} catch {
|
||||
return variants
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function imageVariantFallbackKeys(objectKey: string, variant: string) {
|
||||
const keys = [imageVariantKey(objectKey, variant), objectKey]
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
function normalizeVariant(value: unknown) {
|
||||
const variant = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (variant === IMAGE_VARIANT_THUMB || variant === IMAGE_VARIANT_MEDIUM) {
|
||||
return variant
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import * as Minio from 'minio'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
|
||||
type ObjectStorageConfig = {
|
||||
endpoint: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
region: string
|
||||
}
|
||||
|
||||
type StoredObject = {
|
||||
reader: Readable
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export class ObjectStorage {
|
||||
private readonly client: Minio.Client
|
||||
private readonly bucket: string
|
||||
private readonly region: string
|
||||
private bucketReady = false
|
||||
|
||||
constructor(config: ObjectStorageConfig) {
|
||||
const endpoint = normalizeStorageEndpoint(config.endpoint)
|
||||
this.bucket = String(config.bucket || '').trim()
|
||||
this.region = String(config.region || 'us-east-1').trim() || 'us-east-1'
|
||||
this.client = new Minio.Client({
|
||||
endPoint: endpoint.endPoint,
|
||||
port: endpoint.port,
|
||||
useSSL: endpoint.useSSL,
|
||||
accessKey: String(config.accessKeyId || '').trim(),
|
||||
secretKey: String(config.secretAccessKey || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
async putObject(input: {
|
||||
key: string
|
||||
content: Buffer
|
||||
contentType: string
|
||||
metadata?: Record<string, string>
|
||||
}): Promise<void> {
|
||||
await this.ensureBucketReady()
|
||||
await this.client.putObject(this.bucket, input.key, input.content, input.content.length, {
|
||||
'Content-Type': input.contentType,
|
||||
...sanitizeObjectMetadata(input.metadata),
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<StoredObject> {
|
||||
await this.ensureBucketReady()
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
const stat = await this.client.statObject(this.bucket, objectKey)
|
||||
const reader = await this.client.getObject(this.bucket, objectKey)
|
||||
const metadata = stat.metaData || {}
|
||||
const contentType = String(
|
||||
metadata['content-type'] ||
|
||||
metadata['Content-Type'] ||
|
||||
metadata.contentType ||
|
||||
'application/octet-stream',
|
||||
)
|
||||
|
||||
return {
|
||||
reader,
|
||||
contentType,
|
||||
size: Number(stat.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketReady(): Promise<void> {
|
||||
if (this.bucketReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const exists = await this.client.bucketExists(this.bucket)
|
||||
if (!exists) {
|
||||
await this.client.makeBucket(this.bucket, this.region)
|
||||
}
|
||||
this.bucketReady = true
|
||||
}
|
||||
}
|
||||
|
||||
let storageInstance: ObjectStorage | null = null
|
||||
|
||||
export function getObjectStorage(): ObjectStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = new ObjectStorage({
|
||||
endpoint: runtimeConfig.storage.endpoint,
|
||||
bucket: runtimeConfig.storage.bucket,
|
||||
accessKeyId: runtimeConfig.storage.accessKeyId,
|
||||
secretAccessKey: runtimeConfig.storage.secretAccessKey,
|
||||
region: runtimeConfig.storage.region,
|
||||
})
|
||||
}
|
||||
|
||||
return storageInstance
|
||||
}
|
||||
|
||||
export function normalizeStorageEndpoint(rawEndpoint: unknown) {
|
||||
const endpoint = String(rawEndpoint || '').trim()
|
||||
const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`)
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80
|
||||
|
||||
return {
|
||||
endPoint: parsed.hostname,
|
||||
port,
|
||||
useSSL: parsed.protocol === 'https:',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeObjectKey(rawKey: unknown): string {
|
||||
const key = String(rawKey || '')
|
||||
.trim()
|
||||
.replace(/^\/+/, '')
|
||||
if (!key || key.includes('..') || key.includes('\\')) {
|
||||
throw createHttpError('文件 key 不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'file_key_invalid',
|
||||
})
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function sanitizeObjectMetadata(metadata: Record<string, string> | undefined) {
|
||||
if (!metadata) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata)
|
||||
.map(([key, value]) => [String(key || '').trim(), encodeURIComponent(String(value || ''))])
|
||||
.filter(([key]) => Boolean(key)),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveOrderFulfillmentReadiness,
|
||||
syncOrderFulfillmentAttachments,
|
||||
} from '../fulfillment/order-fulfillment-readiness-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -61,13 +62,15 @@ type UpsertOrderIgnoredResult = {
|
||||
tasks: []
|
||||
}
|
||||
|
||||
type UpsertOrderResult = {
|
||||
ignored?: false
|
||||
ignoreReason?: string
|
||||
order: OrderRow
|
||||
orderItems: OrderItemRow[]
|
||||
tasks: TaskRow[]
|
||||
} | UpsertOrderIgnoredResult
|
||||
type UpsertOrderResult =
|
||||
| {
|
||||
ignored?: false
|
||||
ignoreReason?: string
|
||||
order: OrderRow
|
||||
orderItems: OrderItemRow[]
|
||||
tasks: TaskRow[]
|
||||
}
|
||||
| UpsertOrderIgnoredResult
|
||||
|
||||
type OrderStateInput = {
|
||||
order_status?: string
|
||||
@@ -108,13 +111,17 @@ export async function upsertOrderFromSource(
|
||||
})
|
||||
|
||||
const resolvedItems = await Promise.all(
|
||||
event.items.map((item) => resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
item,
|
||||
})),
|
||||
event.items.map((item) =>
|
||||
resolveOrderItemForFulfillment({
|
||||
provider: event.provider,
|
||||
platform: event.platform,
|
||||
item,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter(
|
||||
(item) => item.isConfigured,
|
||||
)
|
||||
const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured)
|
||||
|
||||
if (configuredItems.length === 0) {
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, {
|
||||
@@ -156,16 +163,16 @@ export async function upsertOrderFromSource(
|
||||
|
||||
const order = existing
|
||||
? await updateOrder(existing.id, {
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
updatedAt: now,
|
||||
})
|
||||
: await createOrder({
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
...basePayload,
|
||||
...mergedPayload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw createHttpError('订单写入失败', {
|
||||
@@ -189,14 +196,19 @@ export async function upsertOrderFromSource(
|
||||
|
||||
const readiness = await resolveOrderFulfillmentReadiness(order)
|
||||
if (!readiness.allowed) {
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`, {
|
||||
orderId: order.id,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
voucherCount: readiness.voucherCount,
|
||||
reason: readiness.reason,
|
||||
}, { level: 'warn' })
|
||||
logIntegration(
|
||||
'[order-service]',
|
||||
`${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`,
|
||||
{
|
||||
orderId: order.id,
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
voucherCount: readiness.voucherCount,
|
||||
reason: readiness.reason,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
|
||||
return {
|
||||
ignoreReason: readiness.blockReason || 'fulfillment_not_ready',
|
||||
@@ -211,6 +223,10 @@ export async function upsertOrderFromSource(
|
||||
source: `${sourceLabel}_order_upsert`,
|
||||
now,
|
||||
})
|
||||
await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: `${sourceLabel}_order_upsert`,
|
||||
autoOnly: true,
|
||||
})
|
||||
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
||||
orderId: order.id,
|
||||
@@ -249,17 +265,23 @@ export function mergeSourceOrderState(
|
||||
existing: OrderStateInput | null | undefined,
|
||||
incoming: OrderStateInput | null | undefined,
|
||||
): OrderStateMergeResult {
|
||||
const orderStatus = chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.order_status ?? existing?.orderStatus),
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status),
|
||||
ORDER_STATUS_PRIORITY,
|
||||
) || normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) || 'created'
|
||||
const orderStatus =
|
||||
chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.order_status ?? existing?.orderStatus),
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status),
|
||||
ORDER_STATUS_PRIORITY,
|
||||
) ||
|
||||
normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) ||
|
||||
'created'
|
||||
|
||||
const payStatus = chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.pay_status ?? existing?.payStatus),
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status),
|
||||
PAY_STATUS_PRIORITY,
|
||||
) || normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) || 'unpaid'
|
||||
const payStatus =
|
||||
chooseHigherPriorityStatus(
|
||||
normalizeStateValue(existing?.pay_status ?? existing?.payStatus),
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status),
|
||||
PAY_STATUS_PRIORITY,
|
||||
) ||
|
||||
normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) ||
|
||||
'unpaid'
|
||||
|
||||
return {
|
||||
orderStatus,
|
||||
@@ -296,7 +318,9 @@ function resolveStatusPriority(value: string, priorityMap: StatusPriorityMap): n
|
||||
}
|
||||
|
||||
function normalizeStateValue(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function resolveMergedPaidAt({
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { query } from '../../../db/client.js'
|
||||
import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } from '../../../repositories/order-repo.js'
|
||||
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
||||
import {
|
||||
createOrder,
|
||||
findOrderByPlatformOrderId,
|
||||
getOrderById,
|
||||
updateOrder,
|
||||
} from '../../../repositories/order-repo.js'
|
||||
import {
|
||||
listOrderItemsByOrderId,
|
||||
replaceOrderItems,
|
||||
} from '../../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import {
|
||||
OPEN_91_PLATFORM,
|
||||
OPEN_91_PROVIDER,
|
||||
} from '../../open-91/config.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
||||
import { normalizeOpen91CreatePayload } from '../../open-91/payload.js'
|
||||
import { buildOpen91OutTradeNo } from '../../open-91/response.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
@@ -179,6 +185,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
||||
source: 'open91_pending_order',
|
||||
now,
|
||||
})
|
||||
await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: 'open91_pending_order',
|
||||
autoOnly: true,
|
||||
})
|
||||
|
||||
return {
|
||||
order,
|
||||
@@ -243,14 +253,15 @@ export async function failOpen91Order(orderId: string | number, reason = '') {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pending_config' } = {}) {
|
||||
export async function listOpen91Orders({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
status = 'pending_config',
|
||||
} = {}) {
|
||||
const normalizedPage = Math.max(1, Number(page) || 1)
|
||||
const normalizedPageSize = Math.min(100, Math.max(1, Number(pageSize) || 20))
|
||||
const normalizedStatus = String(status || 'pending_config').trim()
|
||||
const filters = [
|
||||
'o.provider = $1',
|
||||
'o.platform = $2',
|
||||
]
|
||||
const filters = ['o.provider = $1', 'o.platform = $2']
|
||||
const params: Array<string | number> = [OPEN_91_PROVIDER, OPEN_91_PLATFORM]
|
||||
|
||||
if (normalizedStatus && normalizedStatus !== 'all') {
|
||||
@@ -259,7 +270,10 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${filters.join(' AND ')}`
|
||||
const totalResult = await query(`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, params)
|
||||
const totalResult = await query(
|
||||
`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(normalizedPageSize)
|
||||
params.push((normalizedPage - 1) * normalizedPageSize)
|
||||
@@ -320,7 +334,9 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[]
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const eventItems = normalizedItems.map((item: OrderItemRow) => {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim()
|
||||
const productNo = String(
|
||||
snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '',
|
||||
).trim()
|
||||
|
||||
return {
|
||||
itemId: productNo,
|
||||
@@ -339,9 +355,7 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[]
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER
|
||||
? order.shop_name
|
||||
: '91卡券',
|
||||
shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER ? order.shop_name : '91卡券',
|
||||
platformOrderId: order.platform_order_id,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
@@ -374,7 +388,9 @@ function mapOpen91AdminOrderRow(row: JsonObject) {
|
||||
payStatus: String(row.pay_status || '').trim(),
|
||||
shopId: String(row.shop_id || '').trim(),
|
||||
shopName: String(row.shop_name || '').trim(),
|
||||
productNo: String(firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '').trim(),
|
||||
productNo: String(
|
||||
firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '',
|
||||
).trim(),
|
||||
productName: String(firstSnapshot.externalSkuName || firstItem.skuName || '').trim(),
|
||||
buyNum: Number(firstItem.quantity || 0),
|
||||
taskCount: Number(row.task_count || 0),
|
||||
|
||||
@@ -2,6 +2,8 @@ import crypto from 'node:crypto'
|
||||
|
||||
import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js'
|
||||
import {
|
||||
acceptWorkOrderAndSettle,
|
||||
addWorkerWalletCredit,
|
||||
@@ -12,32 +14,35 @@ import {
|
||||
findPendingMaterialWorkOrderByPlatformOrderId,
|
||||
getWorkCategoryByKey,
|
||||
getWorkOrderById,
|
||||
getWorkOrderByOrderItemId,
|
||||
getWorkerLevelById,
|
||||
getWorkerLevelByKey,
|
||||
getWorkerUserById,
|
||||
getWorkerUserByUsername,
|
||||
listAllWorkCategories,
|
||||
grabWorkOrder,
|
||||
listWorkCategories,
|
||||
listWorkOrders,
|
||||
listWorkProductRules,
|
||||
listWorkerLevels,
|
||||
listWorkerUsers,
|
||||
updateWorkOrder,
|
||||
updateWorkerUser,
|
||||
upsertWorkCategory,
|
||||
upsertWorkProductRule,
|
||||
upsertWorkerLevel,
|
||||
type WorkCategoryRow,
|
||||
type WorkOrderRow,
|
||||
type WorkProductRuleRow,
|
||||
type WorkerLevelRow,
|
||||
type WorkerUserRow,
|
||||
} from '../../repositories/worker-platform-repo.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import { nowIso, addHours } from '../../utils/time.js'
|
||||
import {
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
safeParseJson,
|
||||
} from '../admin/admin-query-utils.js'
|
||||
import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js'
|
||||
|
||||
export type WorkerSession = {
|
||||
sessionId: string
|
||||
@@ -340,7 +345,9 @@ export async function submitWorkerOrderAcceptance(
|
||||
errorCode: 'work_order_owner_required',
|
||||
})
|
||||
}
|
||||
if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)) {
|
||||
if (
|
||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)
|
||||
) {
|
||||
throw createHttpError('当前状态不能提交验收', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_submit_status_invalid',
|
||||
@@ -348,9 +355,21 @@ export async function submitWorkerOrderAcceptance(
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const files = normalizeUploadedFiles(payload.files)
|
||||
const imageUrls = [
|
||||
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
|
||||
...normalizeStringArray(payload.imageUrls),
|
||||
]
|
||||
if (imageUrls.length === 0) {
|
||||
throw createHttpError('请上传验收图片', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_acceptance_image_required',
|
||||
})
|
||||
}
|
||||
const acceptance = {
|
||||
note: String(payload.note || '').trim(),
|
||||
imageUrls: normalizeStringArray(payload.imageUrls),
|
||||
files,
|
||||
imageUrls: [...new Set(imageUrls)],
|
||||
submittedAt: now,
|
||||
}
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
@@ -369,7 +388,12 @@ export async function submitWorkerOrderAcceptance(
|
||||
payloadJson: JSON.stringify(acceptance),
|
||||
now,
|
||||
})
|
||||
return { order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(await getRequiredWorker(session.workerId))) }
|
||||
return {
|
||||
order: mapWorkOrderForWorker(
|
||||
updated || workOrder,
|
||||
resolveWorkerPermissions(await getRequiredWorker(session.workerId)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
|
||||
@@ -411,7 +435,9 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) {
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every((field) => !field.required || String(submittedFields[field.key] || '').trim())
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
@@ -448,12 +474,37 @@ export async function listAdminWorkerLevels() {
|
||||
return { items: (await listWorkerLevels()).map(mapWorkerLevel) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkCategories() {
|
||||
await ensureWorkerPlatformDefaults()
|
||||
return { items: (await listAllWorkCategories()).map(mapWorkCategory) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkCategory(payload: JsonObject = {}) {
|
||||
const now = nowIso()
|
||||
const categoryKey = normalizeSlugKey(
|
||||
payload.categoryKey || payload.category_key || payload.name,
|
||||
DEFAULT_CATEGORY_KEY,
|
||||
)
|
||||
const name = String(payload.name || '').trim() || '默认分类'
|
||||
const category = await upsertWorkCategory({
|
||||
categoryKey,
|
||||
name,
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
status: normalizeEnabledStatus(payload.status),
|
||||
now,
|
||||
})
|
||||
return { category: mapWorkCategory(category) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
const now = nowIso()
|
||||
const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY
|
||||
const name = String(payload.name || '').trim() || '普通接单员'
|
||||
const permissions = {
|
||||
depositFreeAmount: normalizeAmountFen(payload.depositFreeAmount ?? payload.depositFreeAmountYuan, 0),
|
||||
depositFreeAmount: normalizeAmountFen(
|
||||
payload.depositFreeAmount ?? payload.depositFreeAmountYuan,
|
||||
0,
|
||||
),
|
||||
maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3),
|
||||
}
|
||||
const level = await upsertWorkerLevel({
|
||||
@@ -467,6 +518,78 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) {
|
||||
return { level: mapWorkerLevel(level) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductRules(query: JsonObject = {}) {
|
||||
await ensureWorkerPlatformDefaults()
|
||||
const enabledValue = String(query.enabled ?? '').trim()
|
||||
const enabled = enabledValue
|
||||
? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase())
|
||||
: null
|
||||
const items = await listWorkProductRules({
|
||||
enabled,
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
})
|
||||
return { items: items.map(mapWorkProductRule) }
|
||||
}
|
||||
|
||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const productName = String(
|
||||
payload.productName || payload.product_name || payload.skuName || '',
|
||||
).trim()
|
||||
const skuCode = String(payload.skuCode || payload.sku_code || '').trim()
|
||||
if (!productName && !skuCode) {
|
||||
throw createHttpError('请填写商品名或 SKU', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_target_required',
|
||||
})
|
||||
}
|
||||
|
||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||
if (rewardAmount <= 0) {
|
||||
throw createHttpError('接单金额必须大于 0', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_product_rule_reward_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const depositThresholdAmount = normalizeAmountFen(
|
||||
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount =
|
||||
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
|
||||
? rewardAmount > depositThresholdAmount
|
||||
? rewardAmount
|
||||
: 0
|
||||
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
|
||||
const fields = normalizeRequirementFieldsFromPayload(payload)
|
||||
const rule = await upsertWorkProductRule({
|
||||
ruleKey: normalizeSlugKey(
|
||||
payload.ruleKey || payload.rule_key || skuCode || productName,
|
||||
`rule-${Date.now()}`,
|
||||
),
|
||||
provider: String(payload.provider || '').trim(),
|
||||
platform: String(payload.platform || '').trim(),
|
||||
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
||||
skuCode,
|
||||
productName,
|
||||
matchType: normalizeMatchType(payload.matchType || payload.match_type),
|
||||
categoryId:
|
||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||
defaults.category?.id ||
|
||||
null,
|
||||
enabled: normalizeBoolean(payload.enabled, true),
|
||||
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
|
||||
rewardAmount,
|
||||
requiredDepositAmount,
|
||||
depositThresholdAmount,
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
sortOrder: normalizeInteger(payload.sortOrder, 100),
|
||||
now: nowIso(),
|
||||
})
|
||||
return { rule: mapWorkProductRule(rule) }
|
||||
}
|
||||
|
||||
export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
@@ -485,7 +608,11 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
|
||||
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
|
||||
const worker = await getRequiredWorker(workerId)
|
||||
const status = normalizeReviewStatus(payload.status)
|
||||
const levelId = normalizeOptionalId(payload.levelId) || worker.level_id || (await ensureWorkerPlatformDefaults()).level?.id || null
|
||||
const levelId =
|
||||
normalizeOptionalId(payload.levelId) ||
|
||||
worker.level_id ||
|
||||
(await ensureWorkerPlatformDefaults()).level?.id ||
|
||||
null
|
||||
const now = nowIso()
|
||||
const updated = await updateWorkerUser(worker.id, {
|
||||
status,
|
||||
@@ -532,6 +659,66 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitAdminWorkOrderMaterial(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (
|
||||
![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes(
|
||||
workOrder.status as never,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('当前状态不能补充资料', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_material_status_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
const now = nowIso()
|
||||
const material = {
|
||||
...safeParseJson(workOrder.material_json),
|
||||
collect: {
|
||||
fields: submittedFields,
|
||||
submittedAt: now,
|
||||
complete,
|
||||
source: 'admin',
|
||||
},
|
||||
}
|
||||
const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL
|
||||
const updated = await updateWorkOrder(workOrder.id, {
|
||||
status: nextStatus,
|
||||
material_json: material,
|
||||
updated_at: now,
|
||||
})
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: '',
|
||||
eventType: 'material_completed',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: nextStatus,
|
||||
payloadJson: JSON.stringify({ fields: submittedFields, complete }),
|
||||
now,
|
||||
})
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder), complete }
|
||||
}
|
||||
|
||||
export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) {
|
||||
const order = await resolveSourceOrder(payload)
|
||||
const orderItems = await listOrderItemsByOrderId(order.id)
|
||||
const result = await syncWorkerOrdersForSourceOrder(order, orderItems, {
|
||||
source: 'admin_manual_sync',
|
||||
autoOnly: false,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const now = nowIso()
|
||||
@@ -540,12 +727,17 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
||||
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
|
||||
DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount = payload.requiredDepositAmount === undefined
|
||||
? (rewardAmount > depositThresholdAmount ? rewardAmount : 0)
|
||||
: normalizeAmountFen(payload.requiredDepositAmount, 0)
|
||||
const requiredDepositAmount =
|
||||
payload.requiredDepositAmount === undefined
|
||||
? rewardAmount > depositThresholdAmount
|
||||
? rewardAmount
|
||||
: 0
|
||||
: normalizeAmountFen(payload.requiredDepositAmount, 0)
|
||||
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
|
||||
const fields = normalizeRequirementFields(payload.fields)
|
||||
const platformOrderId = String(payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`).trim()
|
||||
const platformOrderId = String(
|
||||
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
|
||||
).trim()
|
||||
const productName = String(payload.productName || '指挥官秘钥1个').trim()
|
||||
const material = materialComplete
|
||||
? {
|
||||
@@ -617,9 +809,16 @@ export async function publishAdminWorkOrder(workOrderId: number | string) {
|
||||
return { order: mapWorkOrderAdmin(updated || workOrder) }
|
||||
}
|
||||
|
||||
export async function markAdminWorkOrderProblem(workOrderId: number | string, payload: JsonObject = {}) {
|
||||
export async function markAdminWorkOrderProblem(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(workOrder.status as never)) {
|
||||
if (
|
||||
![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(
|
||||
workOrder.status as never,
|
||||
)
|
||||
) {
|
||||
throw createHttpError('当前状态不能标记问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_problem_status_invalid',
|
||||
@@ -679,6 +878,96 @@ export async function getAdminWorkerPlatformSummary() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncWorkerOrdersForSourceOrder(
|
||||
order: OrderRow,
|
||||
orderItems: OrderItemRow[],
|
||||
options: { source?: string; autoOnly?: boolean } = {},
|
||||
) {
|
||||
const rules = await listWorkProductRules({ enabled: true })
|
||||
const created: WorkOrderRow[] = []
|
||||
const skipped: Array<{ orderItemId: number; reason: string }> = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const rule = resolveMatchingProductRule(order, item, rules)
|
||||
if (!rule) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' })
|
||||
continue
|
||||
}
|
||||
if (options.autoOnly && !rule.auto_create) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' })
|
||||
continue
|
||||
}
|
||||
if (await getWorkOrderByOrderItemId(item.id)) {
|
||||
skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields)
|
||||
const rewardAmount = Number(rule.reward_amount || 0)
|
||||
const depositThresholdAmount = Number(
|
||||
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
)
|
||||
const requiredDepositAmount =
|
||||
Number(rule.required_deposit_amount || 0) ||
|
||||
(rewardAmount > depositThresholdAmount ? rewardAmount : 0)
|
||||
const now = nowIso()
|
||||
const materialComplete = fields.every((field) => !field.required)
|
||||
const workOrder = await createWorkOrder({
|
||||
workOrderNo: randomId('WO'),
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
taskId: null,
|
||||
platformOrderId: order.platform_order_id,
|
||||
productName: item.sku_name || item.sku_code,
|
||||
categoryId: rule.category_id,
|
||||
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
|
||||
rewardAmount,
|
||||
requiredDepositAmount,
|
||||
depositThresholdAmount,
|
||||
materialJson: JSON.stringify({
|
||||
source: {
|
||||
orderId: Number(order.id),
|
||||
orderItemId: Number(item.id),
|
||||
provider: order.provider,
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id,
|
||||
skuCode: item.sku_code,
|
||||
skuName: item.sku_name,
|
||||
quantity: Number(item.quantity || 1),
|
||||
syncSource: options.source || 'source_order',
|
||||
},
|
||||
}),
|
||||
requirementJson: JSON.stringify({ fields }),
|
||||
now,
|
||||
})
|
||||
|
||||
if (workOrder) {
|
||||
created.push(workOrder)
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'system',
|
||||
actorId: options.source || 'source_order',
|
||||
eventType: 'source_synced',
|
||||
fromStatus: '',
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({
|
||||
ruleKey: rule.rule_key,
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created: created.map(mapWorkOrderAdmin),
|
||||
skipped,
|
||||
createdCount: created.length,
|
||||
skippedCount: skipped.length,
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkerSession(worker: WorkerUserRow) {
|
||||
const issuedAt = nowIso()
|
||||
const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12))
|
||||
@@ -759,6 +1048,37 @@ async function getRequiredWorkOrder(workOrderId: number | string): Promise<WorkO
|
||||
return workOrder
|
||||
}
|
||||
|
||||
async function resolveSourceOrder(payload: JsonObject): Promise<OrderRow> {
|
||||
const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId)
|
||||
if (orderId) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (order) {
|
||||
return order
|
||||
}
|
||||
throw createHttpError('源订单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'source_order_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim()
|
||||
if (!platformOrderId) {
|
||||
throw createHttpError('请填写源订单号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'source_order_no_required',
|
||||
})
|
||||
}
|
||||
|
||||
const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId)
|
||||
if (!order) {
|
||||
throw createHttpError('未找到源订单,请确认订单号是否正确', {
|
||||
statusCode: 404,
|
||||
errorCode: 'source_order_not_found',
|
||||
})
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
const permission = safeParseJson(worker.level_permission_json)
|
||||
return {
|
||||
@@ -767,8 +1087,14 @@ function resolveWorkerPermissions(worker: WorkerUserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFreezeDepositAmount(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) {
|
||||
return Math.max(0, Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0))
|
||||
function resolveFreezeDepositAmount(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0),
|
||||
)
|
||||
}
|
||||
|
||||
function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||
@@ -787,6 +1113,49 @@ function mapWorkerLevel(level: WorkerLevelRow | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
|
||||
if (!category) return null
|
||||
return {
|
||||
categoryId: Number(category.id),
|
||||
categoryKey: category.category_key,
|
||||
name: category.name,
|
||||
sortOrder: Number(category.sort_order || 100),
|
||||
status: category.status,
|
||||
createdAt: category.created_at,
|
||||
updatedAt: category.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
|
||||
if (!rule) return null
|
||||
const requirement = safeParseJson(rule.requirement_json)
|
||||
return {
|
||||
ruleId: Number(rule.id),
|
||||
ruleKey: rule.rule_key,
|
||||
provider: rule.provider || '',
|
||||
platform: rule.platform || '',
|
||||
shopId: rule.shop_id || '',
|
||||
skuCode: rule.sku_code || '',
|
||||
productName: rule.product_name || '',
|
||||
matchType: rule.match_type || 'contains',
|
||||
categoryId: rule.category_id ? Number(rule.category_id) : null,
|
||||
categoryName: rule.category_name || '',
|
||||
enabled: Boolean(rule.enabled),
|
||||
autoCreate: Boolean(rule.auto_create),
|
||||
rewardAmount: Number(rule.reward_amount || 0),
|
||||
requiredDepositAmount: Number(rule.required_deposit_amount || 0),
|
||||
depositThresholdAmount: Number(
|
||||
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
),
|
||||
requirement: {
|
||||
fields: normalizeRequirementFields(requirement.fields),
|
||||
},
|
||||
sortOrder: Number(rule.sort_order || 100),
|
||||
createdAt: rule.created_at,
|
||||
updatedAt: rule.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkerUser(worker: WorkerUserRow) {
|
||||
return {
|
||||
workerId: Number(worker.id),
|
||||
@@ -823,7 +1192,9 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
status: workOrder.status,
|
||||
rewardAmount: Number(workOrder.reward_amount || 0),
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
depositThresholdAmount: Number(workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT),
|
||||
depositThresholdAmount: Number(
|
||||
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||
),
|
||||
material: safeParseJson(workOrder.material_json),
|
||||
requirement: safeParseJson(workOrder.requirement_json),
|
||||
acceptance: safeParseJson(workOrder.acceptance_json),
|
||||
@@ -844,7 +1215,10 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkOrderForWorker(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) {
|
||||
function mapWorkOrderForWorker(
|
||||
workOrder: WorkOrderRow,
|
||||
permissions: { depositFreeAmount: number },
|
||||
) {
|
||||
return {
|
||||
...mapWorkOrderAdmin(workOrder),
|
||||
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
|
||||
@@ -878,7 +1252,8 @@ function normalizeRequirementFields(value: unknown) {
|
||||
const rawFields = Array.isArray(value) ? value : []
|
||||
const fields = rawFields
|
||||
.map((item) => {
|
||||
const source = item && typeof item === 'object' && !Array.isArray(item) ? item as JsonObject : {}
|
||||
const source =
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
@@ -888,7 +1263,9 @@ function normalizeRequirementFields(value: unknown) {
|
||||
mockValue: String(source.mockValue || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => Boolean(item))
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
|
||||
return fields.length > 0
|
||||
? fields
|
||||
@@ -899,8 +1276,113 @@ function normalizeRequirementFields(value: unknown) {
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||
const directFields = Array.isArray(payload.fields) ? payload.fields : null
|
||||
const requirement =
|
||||
payload.requirement &&
|
||||
typeof payload.requirement === 'object' &&
|
||||
!Array.isArray(payload.requirement)
|
||||
? (payload.requirement as JsonObject)
|
||||
: {}
|
||||
if (directFields) {
|
||||
return normalizeRequirementFields(directFields)
|
||||
}
|
||||
if (Array.isArray(requirement.fields)) {
|
||||
return normalizeRequirementFields(requirement.fields)
|
||||
}
|
||||
|
||||
const textFields = normalizeRequirementFieldsText(
|
||||
payload.fieldsText || payload.requiredFieldsText,
|
||||
)
|
||||
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
|
||||
}
|
||||
|
||||
function normalizeRequirementFieldsText(value: unknown) {
|
||||
return String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line, index) => {
|
||||
const text = line.trim()
|
||||
if (!text) return null
|
||||
const delimiter = text.includes(':') ? ':' : ':'
|
||||
if (text.includes(delimiter)) {
|
||||
const [rawKey, ...labelParts] = text.split(delimiter)
|
||||
const key = String(rawKey || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
key,
|
||||
label: labelParts.join(delimiter).trim() || key,
|
||||
required: true,
|
||||
mockValue: '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `field${index + 1}`,
|
||||
label: text,
|
||||
required: true,
|
||||
mockValue: '',
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; required: boolean; mockValue: string } =>
|
||||
Boolean(item),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMatchingProductRule(
|
||||
order: OrderRow,
|
||||
item: OrderItemRow,
|
||||
rules: WorkProductRuleRow[],
|
||||
) {
|
||||
return (
|
||||
rules.find((rule) => {
|
||||
if (!matchesOptionalText(rule.provider, order.provider)) return false
|
||||
if (!matchesOptionalText(rule.platform, order.platform)) return false
|
||||
if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false
|
||||
|
||||
const skuCode = String(item.sku_code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const skuName = String(item.sku_name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const ruleSkuCode = String(rule.sku_code || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const ruleProductName = String(rule.product_name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (ruleSkuCode && ruleSkuCode !== skuCode) {
|
||||
return false
|
||||
}
|
||||
if (!ruleProductName) {
|
||||
return Boolean(ruleSkuCode)
|
||||
}
|
||||
if (rule.match_type === 'exact') {
|
||||
return ruleProductName === skuName || ruleProductName === skuCode
|
||||
}
|
||||
return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName)
|
||||
}) || null
|
||||
)
|
||||
}
|
||||
|
||||
function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) {
|
||||
const ruleText = String(ruleValue || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!ruleText) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
ruleText ===
|
||||
String(sourceValue || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSubmittedFields(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(source)
|
||||
.filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key))
|
||||
@@ -913,14 +1395,80 @@ function normalizeStringArray(value: unknown): string[] {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeUploadedFiles(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.map((item) => {
|
||||
const source =
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||
const url = String(source.url || '').trim()
|
||||
if (!objectKey && !url) return null
|
||||
return {
|
||||
objectKey,
|
||||
url,
|
||||
thumbnailUrl: String(source.thumbnailUrl || source.thumbnail_url || url).trim(),
|
||||
mediumUrl: String(source.mediumUrl || source.medium_url || url).trim(),
|
||||
filename: String(source.filename || '').trim(),
|
||||
contentType: String(source.contentType || source.content_type || '').trim(),
|
||||
size: normalizeInteger(source.size, 0),
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
} => Boolean(item),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeReviewStatus(value: unknown) {
|
||||
const status = String(value || '').trim()
|
||||
if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status
|
||||
return 'pending_review'
|
||||
}
|
||||
|
||||
function normalizeEnabledStatus(value: unknown) {
|
||||
return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active'
|
||||
}
|
||||
|
||||
function normalizeMatchType(value: unknown) {
|
||||
const matchType = String(value || '').trim()
|
||||
if (matchType === 'exact') return 'exact'
|
||||
return 'contains'
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean) {
|
||||
if (typeof value === 'boolean') return value
|
||||
const text = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!text) return fallback
|
||||
if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true
|
||||
if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeSlugKey(value: unknown, fallback: string) {
|
||||
const key = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return key || fallback
|
||||
}
|
||||
|
||||
function normalizeUsername(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizePassword(value: unknown): string {
|
||||
|
||||
@@ -1,116 +1,124 @@
|
||||
export type AdminDefaultUser = {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
};
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type KuaishouFeifeiProductRule = {
|
||||
id?: string;
|
||||
productName: string;
|
||||
normalizedProductName?: string;
|
||||
productCode: string;
|
||||
skuName?: string;
|
||||
enabled?: boolean;
|
||||
notes?: string;
|
||||
};
|
||||
id?: string
|
||||
productName: string
|
||||
normalizedProductName?: string
|
||||
productCode: string
|
||||
skuName?: string
|
||||
enabled?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type RuntimeConfig = {
|
||||
server: {
|
||||
port: number;
|
||||
};
|
||||
port: number
|
||||
}
|
||||
data: {
|
||||
root: string;
|
||||
};
|
||||
root: string
|
||||
}
|
||||
logging: {
|
||||
level: string;
|
||||
level: string
|
||||
/** integration 通道(外部对接日志文件)最低级别,默认 warn */
|
||||
integrationLevel: string;
|
||||
retentionDays: number;
|
||||
};
|
||||
integrationLevel: string
|
||||
retentionDays: number
|
||||
}
|
||||
database: {
|
||||
url: string;
|
||||
ssl: boolean;
|
||||
maxConnections: number;
|
||||
};
|
||||
url: string
|
||||
ssl: boolean
|
||||
maxConnections: number
|
||||
}
|
||||
storage: {
|
||||
endpoint: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
region: string
|
||||
maxUploadSizeMb: number
|
||||
}
|
||||
orders: {
|
||||
claimBaseUrl: string;
|
||||
tokenTtlHours: number;
|
||||
};
|
||||
claimBaseUrl: string
|
||||
tokenTtlHours: number
|
||||
}
|
||||
admin: {
|
||||
sessionSecret: string;
|
||||
sessionTtlHours: number;
|
||||
defaultUsers: AdminDefaultUser[];
|
||||
};
|
||||
sessionSecret: string
|
||||
sessionTtlHours: number
|
||||
defaultUsers: AdminDefaultUser[]
|
||||
}
|
||||
platforms: {
|
||||
ninetyone: {
|
||||
userId: string;
|
||||
secret: string;
|
||||
version: string;
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
timestampToleranceSeconds: number;
|
||||
cardsEncoding: string;
|
||||
};
|
||||
userId: string
|
||||
secret: string
|
||||
version: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
timestampToleranceSeconds: number
|
||||
cardsEncoding: string
|
||||
}
|
||||
cloudtentacles: {
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
sendSmsPath: string;
|
||||
loginPath: string;
|
||||
userInfoPath: string;
|
||||
assetPath: string;
|
||||
permissionPath: string;
|
||||
categoriesPath: string;
|
||||
skuListPath: string;
|
||||
skuBuyPath: string;
|
||||
skuUsePath: string;
|
||||
knapsackPath: string;
|
||||
vnListPath: string;
|
||||
vnAppointPath: string;
|
||||
vnGenerateLoginCodePath: string;
|
||||
vnVerifCodePath: string;
|
||||
vnVerifyLoginCodePath: string;
|
||||
vnBindUrlPath: string;
|
||||
vnBindInfoPath: string;
|
||||
vnBackPath: string;
|
||||
bindUrlTtlSeconds: number;
|
||||
bindUrlProbeIntervalSeconds: number;
|
||||
bindUrlProbeTimeoutMs: number;
|
||||
bindUrlProbeUserAgent: string;
|
||||
bindUrlProbeEndpoint: string;
|
||||
bindUrlProbeChartId: string;
|
||||
bindUrlProbeSubChartId: string;
|
||||
bindUrlProbeIdeToken: string;
|
||||
bindUrlProbeActivityUrl: string;
|
||||
bindUrlProbeReferer: string;
|
||||
bindUrlProbeExtraCookie: string;
|
||||
publicKeyPem: string;
|
||||
clientSource: string;
|
||||
deviceId: string;
|
||||
deviceType: number;
|
||||
};
|
||||
baseUrl: string
|
||||
timeoutMs: number
|
||||
sendSmsPath: string
|
||||
loginPath: string
|
||||
userInfoPath: string
|
||||
assetPath: string
|
||||
permissionPath: string
|
||||
categoriesPath: string
|
||||
skuListPath: string
|
||||
skuBuyPath: string
|
||||
skuUsePath: string
|
||||
knapsackPath: string
|
||||
vnListPath: string
|
||||
vnAppointPath: string
|
||||
vnGenerateLoginCodePath: string
|
||||
vnVerifCodePath: string
|
||||
vnVerifyLoginCodePath: string
|
||||
vnBindUrlPath: string
|
||||
vnBindInfoPath: string
|
||||
vnBackPath: string
|
||||
bindUrlTtlSeconds: number
|
||||
bindUrlProbeIntervalSeconds: number
|
||||
bindUrlProbeTimeoutMs: number
|
||||
bindUrlProbeUserAgent: string
|
||||
bindUrlProbeEndpoint: string
|
||||
bindUrlProbeChartId: string
|
||||
bindUrlProbeSubChartId: string
|
||||
bindUrlProbeIdeToken: string
|
||||
bindUrlProbeActivityUrl: string
|
||||
bindUrlProbeReferer: string
|
||||
bindUrlProbeExtraCookie: string
|
||||
publicKeyPem: string
|
||||
clientSource: string
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
kuaishouIndustry: {
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
signSecret: string;
|
||||
messageSecret: string;
|
||||
provider: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
version: string;
|
||||
};
|
||||
appKey: string
|
||||
appSecret: string
|
||||
signSecret: string
|
||||
messageSecret: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
shopName: string
|
||||
version: string
|
||||
}
|
||||
kuaishouFeifei: {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
appKey: string;
|
||||
appSecret: string;
|
||||
timeoutMs: number;
|
||||
notifyUrl: string;
|
||||
productRules: KuaishouFeifeiProductRule[];
|
||||
};
|
||||
};
|
||||
enabled: boolean
|
||||
baseUrl: string
|
||||
appKey: string
|
||||
appSecret: string
|
||||
timeoutMs: number
|
||||
notifyUrl: string
|
||||
productRules: KuaishouFeifeiProductRule[]
|
||||
}
|
||||
}
|
||||
cors: {
|
||||
allowedOrigins: string[];
|
||||
};
|
||||
};
|
||||
allowedOrigins: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Image, Typography } from 'antd'
|
||||
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
type ImagePreviewListProps = {
|
||||
files?: UploadedFile[]
|
||||
imageUrls?: string[]
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function ImagePreviewList({
|
||||
files = [],
|
||||
imageUrls = [],
|
||||
size = 56,
|
||||
}: ImagePreviewListProps) {
|
||||
const items = normalizePreviewItems(files, imageUrls)
|
||||
if (items.length === 0) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Image.PreviewGroup>
|
||||
<div className="image-preview-list">
|
||||
{items.map((item) => (
|
||||
<Image
|
||||
key={item.url}
|
||||
src={item.thumbnailUrl || item.url}
|
||||
preview={{ src: item.mediumUrl || item.url }}
|
||||
width={size}
|
||||
height={size}
|
||||
className="image-preview-item"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreviewItems(files: UploadedFile[], imageUrls: string[]) {
|
||||
const normalizedFiles = files
|
||||
.map((file) => ({
|
||||
url: file.url || file.mediumUrl || file.thumbnailUrl,
|
||||
thumbnailUrl: file.thumbnailUrl || file.url,
|
||||
mediumUrl: file.mediumUrl || file.url,
|
||||
}))
|
||||
.filter((item) => Boolean(item.url))
|
||||
const fileUrlSet = new Set(normalizedFiles.map((item) => item.url))
|
||||
const normalizedUrls = imageUrls
|
||||
.map((url) => String(url || '').trim())
|
||||
.filter((url) => url && !fileUrlSet.has(url))
|
||||
.map((url) => ({ url, thumbnailUrl: url, mediumUrl: url }))
|
||||
return [...normalizedFiles, ...normalizedUrls]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { App, Upload } from 'antd'
|
||||
import type { UploadFile, UploadProps } from 'antd'
|
||||
|
||||
import { uploadFile } from '@/services/files'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
type ImageUploadProps = {
|
||||
value?: UploadedFile[]
|
||||
onChange?: (files: UploadedFile[]) => void
|
||||
scene: string
|
||||
scope: 'admin' | 'worker'
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
value = [],
|
||||
onChange,
|
||||
scene,
|
||||
scope,
|
||||
maxCount = 6,
|
||||
}: ImageUploadProps) {
|
||||
const { message } = App.useApp()
|
||||
const fileList: UploadFile[] = value.map((file) => ({
|
||||
uid: file.objectKey || file.url,
|
||||
name: file.filename || '图片',
|
||||
status: 'done',
|
||||
url: file.thumbnailUrl || file.url,
|
||||
thumbUrl: file.thumbnailUrl || file.url,
|
||||
}))
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||
try {
|
||||
const uploaded = await uploadFile(options.file as File, scene, scope)
|
||||
onChange?.([...value, uploaded].slice(-maxCount))
|
||||
options.onSuccess?.(uploaded)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '上传失败')
|
||||
options.onError?.(error instanceof Error ? error : new Error('上传失败'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Upload
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
maxCount={maxCount}
|
||||
customRequest={customRequest}
|
||||
onRemove={(file) => {
|
||||
onChange?.(
|
||||
value.filter((item) => (item.objectKey || item.url) !== file.uid),
|
||||
)
|
||||
return true
|
||||
}}
|
||||
showUploadList={{ showPreviewIcon: false }}
|
||||
>
|
||||
{value.length >= maxCount ? null : (
|
||||
<button className="image-upload-trigger" type="button">
|
||||
<PlusOutlined />
|
||||
<span>上传</span>
|
||||
</button>
|
||||
)}
|
||||
</Upload>
|
||||
)
|
||||
}
|
||||
@@ -36,9 +36,13 @@ http.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
const responseMessage =
|
||||
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
||||
typeof error?.response?.data?.msg === 'string'
|
||||
? error.response.data.msg.trim()
|
||||
: ''
|
||||
const fallbackMessage = resolveFallbackHttpMessage(error)
|
||||
const normalizedError = new Error(responseMessage || fallbackMessage) as Error & {
|
||||
const normalizedError = new Error(
|
||||
responseMessage || fallbackMessage,
|
||||
) as Error & {
|
||||
errorCode?: string
|
||||
status?: number
|
||||
}
|
||||
@@ -51,7 +55,10 @@ http.interceptors.response.use(
|
||||
normalizedError.status = error.response.status
|
||||
}
|
||||
|
||||
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) {
|
||||
if (
|
||||
error?.config?.url &&
|
||||
String(error.config.url).startsWith('/api/v1/admin')
|
||||
) {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||
|
||||
@@ -67,7 +74,10 @@ http.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/worker')) {
|
||||
if (
|
||||
error?.config?.url &&
|
||||
String(error.config.url).startsWith('/api/v1/worker')
|
||||
) {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||
|
||||
@@ -118,8 +128,13 @@ function resolveFallbackHttpMessage(error: unknown) {
|
||||
return '网络连接错误'
|
||||
}
|
||||
|
||||
if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') {
|
||||
return String((error as { response: { statusText: string } }).response.statusText).trim()
|
||||
if (
|
||||
typeof (error as { response?: { statusText?: string } })?.response
|
||||
?.statusText === 'string'
|
||||
) {
|
||||
return String(
|
||||
(error as { response: { statusText: string } }).response.statusText,
|
||||
).trim()
|
||||
}
|
||||
|
||||
return '接口请求失败'
|
||||
@@ -145,7 +160,9 @@ function shouldClearWorkerSession(errorCode: string) {
|
||||
].includes(errorCode)
|
||||
}
|
||||
|
||||
function request<T>(config: Parameters<typeof http.request<ApiEnvelope<T>>>[0]) {
|
||||
function request<T>(
|
||||
config: Parameters<typeof http.request<ApiEnvelope<T>>>[0],
|
||||
) {
|
||||
return http.request<ApiEnvelope<T>, ApiEnvelope<T>>(config)
|
||||
}
|
||||
|
||||
@@ -163,7 +180,20 @@ export function apiGetBlob(url: string, params?: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, data?: unknown) {
|
||||
return request<T>({ method: 'POST', url, data: data as Record<string, unknown> })
|
||||
return request<T>({
|
||||
method: 'POST',
|
||||
url,
|
||||
data: data as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
export function apiPostForm<T>(url: string, data: FormData) {
|
||||
return request<T>({
|
||||
method: 'POST',
|
||||
url,
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function apiDelete<T>(url: string) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
FormOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -26,11 +28,14 @@ import {
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import {
|
||||
acceptAdminWorkOrder,
|
||||
createAdminMockWorkOrder,
|
||||
creditAdminWorkerWallet,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkProductRules,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerPlatformSummary,
|
||||
fetchAdminWorkerUsers,
|
||||
@@ -38,19 +43,36 @@ import {
|
||||
markAdminWorkOrderProblem,
|
||||
publishAdminWorkOrder,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkerLevel,
|
||||
submitAdminWorkOrderMaterial,
|
||||
syncAdminWorkerOrdersFromSource,
|
||||
} from '@/services/admin'
|
||||
import type { WorkOrder, WorkerLevel, WorkerUser } from '@/types/worker-platform'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkProductRule,
|
||||
WorkerLevel,
|
||||
WorkerUser,
|
||||
} from '@/types/worker-platform'
|
||||
|
||||
export default function AdminWorkerPlatformPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="接单平台" description="管理接单员、接单工单、等级权限和本地 Mock 工单。" />
|
||||
<PageHeader
|
||||
title="接单平台"
|
||||
description="管理接单员、接单工单、等级权限和本地 Mock 工单。"
|
||||
/>
|
||||
<SummaryCards />
|
||||
<Tabs
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '接单员', children: <WorkersPanel /> },
|
||||
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
||||
]}
|
||||
@@ -69,10 +91,16 @@ function SummaryCards() {
|
||||
return (
|
||||
<div className="metric-grid four">
|
||||
<Card>
|
||||
<Statistic title="待审核接单员" value={summary?.pendingWorkerCount || 0} />
|
||||
<Statistic
|
||||
title="待审核接单员"
|
||||
value={summary?.pendingWorkerCount || 0}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic title="待完善订单" value={summary?.pendingMaterialCount || 0} />
|
||||
<Statistic
|
||||
title="待完善订单"
|
||||
value={summary?.pendingMaterialCount || 0}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic title="大厅订单" value={summary?.openOrderCount || 0} />
|
||||
@@ -89,7 +117,9 @@ function WorkOrdersPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-orders', status],
|
||||
@@ -98,12 +128,19 @@ function WorkOrdersPanel() {
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-orders'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<unknown>, successMessage: string) {
|
||||
async function runAction(
|
||||
action: () => Promise<unknown>,
|
||||
successMessage: string,
|
||||
) {
|
||||
try {
|
||||
await action()
|
||||
message.success(successMessage)
|
||||
@@ -116,13 +153,47 @@ function WorkOrdersPanel() {
|
||||
async function submitProblem(values: { note?: string }) {
|
||||
if (!problemOrder) return
|
||||
await runAction(
|
||||
() => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
|
||||
() =>
|
||||
markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
|
||||
'已标记问题单',
|
||||
)
|
||||
setProblemOrder(null)
|
||||
problemForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitMaterial(values: { fields?: Record<string, string> }) {
|
||||
if (!materialOrder) return
|
||||
await runAction(
|
||||
() =>
|
||||
submitAdminWorkOrderMaterial(
|
||||
materialOrder.workOrderId,
|
||||
values.fields || {},
|
||||
),
|
||||
'订单资料已保存',
|
||||
)
|
||||
setMaterialOrder(null)
|
||||
materialForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitSourceSync(values: { platformOrderId?: string }) {
|
||||
try {
|
||||
const result = await syncAdminWorkerOrdersFromSource({
|
||||
platformOrderId: String(values.platformOrderId || '').trim(),
|
||||
})
|
||||
message.success(
|
||||
`同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`,
|
||||
)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openMaterialModal(row: WorkOrder) {
|
||||
setMaterialOrder(row)
|
||||
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkOrder> = [
|
||||
{
|
||||
title: '订单',
|
||||
@@ -136,35 +207,93 @@ function WorkOrdersPanel() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '金额', width: 110, render: (_, row) => formatMoney(row.rewardAmount) },
|
||||
{ title: '押金', width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount) },
|
||||
{
|
||||
title: '金额',
|
||||
width: 110,
|
||||
render: (_, row) => formatMoney(row.rewardAmount),
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
width: 110,
|
||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 130,
|
||||
render: (_, row) => <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tag color={resolveStatusColor(row.status)}>
|
||||
{formatStatus(row.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '接单员',
|
||||
width: 140,
|
||||
render: (_, row) => row.worker?.displayName || row.worker?.username || '-',
|
||||
render: (_, row) =>
|
||||
row.worker?.displayName || row.worker?.username || '-',
|
||||
},
|
||||
{
|
||||
title: '资料',
|
||||
width: 160,
|
||||
render: (_, row) => renderMaterialSummary(row),
|
||||
},
|
||||
{
|
||||
title: '验收图',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<ImagePreviewList
|
||||
files={getAcceptanceFiles(row)}
|
||||
imageUrls={getAcceptanceImageUrls(row)}
|
||||
size={44}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
width: 340,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
{row.status === 'pending_material' ? (
|
||||
<Button
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => openMaterialModal(row)}
|
||||
>
|
||||
补资料
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'unassigned' ? (
|
||||
<Button icon={<SendOutlined />} onClick={() => runAction(() => publishAdminWorkOrder(row.workOrderId), '已发布到大厅')}>
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => publishAdminWorkOrder(row.workOrderId),
|
||||
'已发布到大厅',
|
||||
)
|
||||
}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'pending_acceptance' ? (
|
||||
<Button icon={<CheckOutlined />} type="primary" onClick={() => runAction(() => acceptAdminWorkOrder(row.workOrderId), '已验收通过')}>
|
||||
<Button
|
||||
icon={<CheckOutlined />}
|
||||
type="primary"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => acceptAdminWorkOrder(row.workOrderId),
|
||||
'已验收通过',
|
||||
)
|
||||
}
|
||||
>
|
||||
验收
|
||||
</Button>
|
||||
) : null}
|
||||
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
|
||||
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
||||
<Button
|
||||
icon={<WarningOutlined />}
|
||||
danger
|
||||
onClick={() => setProblemOrder(row)}
|
||||
>
|
||||
问题单
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -175,6 +304,25 @@ function WorkOrdersPanel() {
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="从源订单同步" bordered={false}>
|
||||
<Form layout="inline" onFinish={submitSourceSync}>
|
||||
<Form.Item
|
||||
label="平台订单号"
|
||||
name="platformOrderId"
|
||||
rules={[{ required: true, message: '请输入平台订单号' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="用户填写链接 A 的订单号"
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SyncOutlined />} htmlType="submit">
|
||||
同步
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title="生成本地接单工单 Mock" bordered={false}>
|
||||
<MockWorkOrderForm onCreated={refreshAll} />
|
||||
</Card>
|
||||
@@ -197,7 +345,11 @@ function WorkOrdersPanel() {
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={() => ordersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -222,11 +374,40 @@ function WorkOrdersPanel() {
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={problemForm} layout="vertical" onFinish={submitProblem}>
|
||||
<Form.Item label="问题备注" name="note" rules={[{ required: true, message: '请填写问题备注' }]}>
|
||||
<Form.Item
|
||||
label="问题备注"
|
||||
name="note"
|
||||
rules={[{ required: true, message: '请填写问题备注' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="人工补充资料"
|
||||
open={Boolean(materialOrder)}
|
||||
onCancel={() => setMaterialOrder(null)}
|
||||
onOk={() => materialForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
|
||||
{getRequirementFields(materialOrder).map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
name={['fields', field.key]}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -256,7 +437,11 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
return (
|
||||
<Form
|
||||
layout="inline"
|
||||
initialValues={{ productName: '指挥官秘钥1个', rewardAmount: 25, materialComplete: false }}
|
||||
initialValues={{
|
||||
productName: '指挥官秘钥1个',
|
||||
rewardAmount: 25,
|
||||
materialComplete: false,
|
||||
}}
|
||||
onFinish={submit}
|
||||
>
|
||||
<Form.Item label="订单号" name="platformOrderId">
|
||||
@@ -268,11 +453,20 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
<Form.Item label="接单金额" name="rewardAmount">
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资料完整" name="materialComplete" valuePropName="checked">
|
||||
<Form.Item
|
||||
label="资料完整"
|
||||
name="materialComplete"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
>
|
||||
生成
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@@ -280,6 +474,332 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProductRulesPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
queryFn: () => fetchAdminWorkProductRules(),
|
||||
})
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
queryFn: () => fetchAdminWorkCategories(),
|
||||
})
|
||||
|
||||
async function saveRule(values: {
|
||||
ruleKey: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
matchType?: string
|
||||
categoryId?: number
|
||||
rewardAmount?: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
fieldsText?: string
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
}) {
|
||||
try {
|
||||
await saveAdminWorkProductRule(values)
|
||||
message.success('物品规则已保存')
|
||||
form.resetFields()
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
{
|
||||
title: '规则',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>
|
||||
{row.productName || row.skuCode}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.ruleKey}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SKU',
|
||||
dataIndex: 'skuCode',
|
||||
width: 180,
|
||||
render: (value) => String(value || '-'),
|
||||
},
|
||||
{ title: '分类', width: 140, render: (_, row) => row.categoryName || '-' },
|
||||
{
|
||||
title: '接单金额',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.rewardAmount),
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
width: 130,
|
||||
render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Tag color={row.enabled ? 'green' : 'default'}>
|
||||
{row.enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="保存物品规则" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="worker-rule-form"
|
||||
initialValues={{
|
||||
platform: 'kuaishou',
|
||||
matchType: 'contains',
|
||||
rewardAmount: 25,
|
||||
depositThresholdAmount: 200,
|
||||
enabled: true,
|
||||
autoCreate: false,
|
||||
fieldsText:
|
||||
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
||||
}}
|
||||
onFinish={saveRule}
|
||||
>
|
||||
<div className="worker-rule-grid">
|
||||
<Form.Item
|
||||
label="规则标识"
|
||||
name="ruleKey"
|
||||
rules={[{ required: true, message: '请输入规则标识' }]}
|
||||
>
|
||||
<Input placeholder="skin-training" />
|
||||
</Form.Item>
|
||||
<Form.Item label="来源" name="provider">
|
||||
<Input placeholder="留空通配,如 91kaquan" />
|
||||
</Form.Item>
|
||||
<Form.Item label="平台" name="platform">
|
||||
<Input placeholder="kuaishou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="店铺" name="shopId">
|
||||
<Input placeholder="留空通配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SKU" name="skuCode">
|
||||
<Input placeholder="优先精确匹配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品名" name="productName">
|
||||
<Input placeholder="用于包含/精确匹配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="匹配方式" name="matchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'exact', label: '精确' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="分类" name="categoryId">
|
||||
<Select
|
||||
allowClear
|
||||
loading={categoriesQuery.isLoading}
|
||||
options={(categoriesQuery.data?.data.items || []).map(
|
||||
(item) => ({
|
||||
value: item.categoryId,
|
||||
label: item.name,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="接单金额" name="rewardAmount">
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="押金阈值" name="depositThresholdAmount">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="固定押金" name="requiredDepositAmount">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="排序" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自动创建"
|
||||
name="autoCreate"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="资料字段" name="fieldsText">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存规则
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card
|
||||
title="物品规则"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={rulesQuery.isFetching}
|
||||
onClick={() => rulesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Table<WorkProductRule>
|
||||
rowKey="ruleId"
|
||||
loading={rulesQuery.isLoading}
|
||||
dataSource={rulesQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoriesPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
queryFn: () => fetchAdminWorkCategories(),
|
||||
})
|
||||
|
||||
async function saveCategory(values: {
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
status?: string
|
||||
}) {
|
||||
try {
|
||||
await saveAdminWorkCategory(values)
|
||||
message.success('分类已保存')
|
||||
form.resetFields()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkCategory> = [
|
||||
{ title: '分类', dataIndex: 'name' },
|
||||
{ title: '标识', dataIndex: 'categoryKey' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.status === 'active' ? 'green' : 'default'}>
|
||||
{row.status === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="保存分类" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
initialValues={{ status: 'active', sortOrder: 100 }}
|
||||
onFinish={saveCategory}
|
||||
>
|
||||
<Form.Item
|
||||
label="标识"
|
||||
name="categoryKey"
|
||||
rules={[{ required: true, message: '请输入标识' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="排序" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title="分类列表" bordered={false}>
|
||||
<Table<WorkCategory>
|
||||
rowKey="categoryId"
|
||||
loading={categoriesQuery.isLoading}
|
||||
dataSource={categoriesQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkersPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -294,8 +814,12 @@ function WorkersPanel() {
|
||||
|
||||
async function refreshWorkers() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-workers'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -331,26 +855,44 @@ function WorkersPanel() {
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.displayName || row.username}</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{row.displayName || row.username}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.username}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
|
||||
{ title: '余额', width: 120, render: (_, row) => formatMoney(row.wallet.availableAmount) },
|
||||
{ title: '冻结', width: 120, render: (_, row) => formatMoney(row.wallet.frozenDepositAmount) },
|
||||
{
|
||||
title: '余额',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.wallet.availableAmount),
|
||||
},
|
||||
{
|
||||
title: '冻结',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.wallet.frozenDepositAmount),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 130,
|
||||
render: (_, row) => <Tag color={row.status === 'active' ? 'green' : 'orange'}>{formatWorkerStatus(row.status)}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.status === 'active' ? 'green' : 'orange'}>
|
||||
{formatWorkerStatus(row.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
{row.status !== 'active' ? <Button onClick={() => review(row, 'active')}>通过</Button> : null}
|
||||
{row.status !== 'rejected' ? <Button onClick={() => review(row, 'rejected')}>拒绝</Button> : null}
|
||||
{row.status !== 'active' ? (
|
||||
<Button onClick={() => review(row, 'active')}>通过</Button>
|
||||
) : null}
|
||||
{row.status !== 'rejected' ? (
|
||||
<Button onClick={() => review(row, 'rejected')}>拒绝</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setCreditWorker(row)}>充值</Button>
|
||||
</Space>
|
||||
),
|
||||
@@ -374,7 +916,11 @@ function WorkersPanel() {
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} loading={workersQuery.isFetching} onClick={() => workersQuery.refetch()}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={workersQuery.isFetching}
|
||||
onClick={() => workersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -398,8 +944,17 @@ function WorkersPanel() {
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={creditForm} layout="vertical" onFinish={submitCredit}>
|
||||
<Form.Item label="金额" name="amount" rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<InputNumber min={0.01} step={10} addonAfter="元" className="full-width" />
|
||||
<Form.Item
|
||||
label="金额"
|
||||
name="amount"
|
||||
rules={[{ required: true, message: '请输入金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={10}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="note">
|
||||
<Input />
|
||||
@@ -419,11 +974,18 @@ function LevelsPanel() {
|
||||
})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
async function saveLevel(values: { levelKey: string; name: string; depositFreeAmount?: number; maxActiveOrders?: number }) {
|
||||
async function saveLevel(values: {
|
||||
levelKey: string
|
||||
name: string
|
||||
depositFreeAmount?: number
|
||||
maxActiveOrders?: number
|
||||
}) {
|
||||
try {
|
||||
await saveAdminWorkerLevel(values)
|
||||
message.success('等级已保存')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-levels'] })
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-levels'],
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
@@ -432,7 +994,10 @@ function LevelsPanel() {
|
||||
const columns: TableColumnsType<WorkerLevel> = [
|
||||
{ title: '等级', dataIndex: 'name' },
|
||||
{ title: '标识', dataIndex: 'levelKey' },
|
||||
{ title: '免押额度', render: (_, row) => formatMoney(row.depositFreeAmount) },
|
||||
{
|
||||
title: '免押额度',
|
||||
render: (_, row) => formatMoney(row.depositFreeAmount),
|
||||
},
|
||||
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
]
|
||||
@@ -443,13 +1008,26 @@ function LevelsPanel() {
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
initialValues={{ levelKey: 'regular', name: '普通接单员', depositFreeAmount: 0, maxActiveOrders: 3 }}
|
||||
initialValues={{
|
||||
levelKey: 'regular',
|
||||
name: '普通接单员',
|
||||
depositFreeAmount: 0,
|
||||
maxActiveOrders: 3,
|
||||
}}
|
||||
onFinish={saveLevel}
|
||||
>
|
||||
<Form.Item label="标识" name="levelKey" rules={[{ required: true, message: '请输入标识' }]}>
|
||||
<Form.Item
|
||||
label="标识"
|
||||
name="levelKey"
|
||||
rules={[{ required: true, message: '请输入标识' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="免押额度" name="depositFreeAmount">
|
||||
@@ -459,7 +1037,9 @@ function LevelsPanel() {
|
||||
<InputNumber min={1} max={999} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
@@ -476,6 +1056,77 @@ function LevelsPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||
if (!order) return []
|
||||
const rawFields = Array.isArray(order.requirement?.fields)
|
||||
? order.requirement.fields
|
||||
: []
|
||||
return rawFields
|
||||
.map((item) => {
|
||||
const source = asRecord(item)
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
key,
|
||||
label: String(source.label || key).trim(),
|
||||
required: source.required !== false,
|
||||
}
|
||||
})
|
||||
.filter((item): item is CollectField => Boolean(item))
|
||||
}
|
||||
|
||||
function getMaterialFields(order: WorkOrder): Record<string, string> {
|
||||
const material = asRecord(order.material)
|
||||
const collect = asRecord(material.collect)
|
||||
const fields = asRecord(collect.fields || material.fields)
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
|
||||
)
|
||||
}
|
||||
|
||||
function renderMaterialSummary(order: WorkOrder) {
|
||||
const fields = getMaterialFields(order)
|
||||
const entries = Object.entries(fields).filter(([, value]) =>
|
||||
String(value || '').trim(),
|
||||
)
|
||||
if (entries.length === 0) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
{entries.slice(0, 2).map(([key, value]) => (
|
||||
<Typography.Text key={key} ellipsis>
|
||||
{key}: {value}
|
||||
</Typography.Text>
|
||||
))}
|
||||
{entries.length > 2 ? (
|
||||
<Typography.Text type="secondary">
|
||||
还有 {entries.length - 2} 项
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||
}
|
||||
|
||||
function getAcceptanceImageUrls(row: WorkOrder): string[] {
|
||||
return Array.isArray(row.acceptance?.imageUrls)
|
||||
? row.acceptance.imageUrls
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function formatMoney(value: number | undefined) {
|
||||
return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
@@ -31,6 +33,7 @@ export default function WorkerOrdersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [submittingOrder, setSubmittingOrder] = useState<WorkOrder | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
@@ -39,18 +42,20 @@ export default function WorkerOrdersPage() {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
async function submitAcceptance(values: { note?: string; imageUrls?: string }) {
|
||||
async function submitAcceptance(values: { note?: string }) {
|
||||
if (!submittingOrder) return
|
||||
if (acceptanceFiles.length === 0) {
|
||||
message.error('请上传验收图片')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await submitWorkerAcceptance(submittingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
imageUrls: String(values.imageUrls || '')
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
files: acceptanceFiles,
|
||||
})
|
||||
message.success('已提交验收')
|
||||
setSubmittingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
form.resetFields()
|
||||
await queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
|
||||
} catch (error) {
|
||||
@@ -65,19 +70,40 @@ export default function WorkerOrdersPage() {
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.productName}</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.platformOrderId}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{row.platformOrderId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
width: 130,
|
||||
render: (_, row) => <Typography.Text strong>{formatMoney(row.rewardAmount)}</Typography.Text>,
|
||||
render: (_, row) => (
|
||||
<Typography.Text strong>
|
||||
{formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 140,
|
||||
render: (_, row) => <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tag color={resolveStatusColor(row.status)}>
|
||||
{formatStatus(row.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '验收图片',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<ImagePreviewList
|
||||
files={getAcceptanceFiles(row)}
|
||||
imageUrls={getAcceptanceImageUrls(row)}
|
||||
size={46}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '问题备注',
|
||||
@@ -90,7 +116,13 @@ export default function WorkerOrdersPage() {
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
['in_progress', 'problem'].includes(row.status) ? (
|
||||
<Button icon={<CheckOutlined />} onClick={() => setSubmittingOrder(row)}>
|
||||
<Button
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => {
|
||||
setSubmittingOrder(row)
|
||||
setAcceptanceFiles([])
|
||||
}}
|
||||
>
|
||||
提交验收
|
||||
</Button>
|
||||
) : null,
|
||||
@@ -102,8 +134,17 @@ export default function WorkerOrdersPage() {
|
||||
<div className="worker-page-head">
|
||||
<Typography.Title level={3}>我的订单</Typography.Title>
|
||||
<Space>
|
||||
<Select value={status} options={STATUS_OPTIONS} style={{ width: 140 }} onChange={setStatus} />
|
||||
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
|
||||
<Select
|
||||
value={status}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={() => ordersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -121,7 +162,10 @@ export default function WorkerOrdersPage() {
|
||||
<Modal
|
||||
title="提交验收"
|
||||
open={Boolean(submittingOrder)}
|
||||
onCancel={() => setSubmittingOrder(null)}
|
||||
onCancel={() => {
|
||||
setSubmittingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -129,12 +173,14 @@ export default function WorkerOrdersPage() {
|
||||
<Form.Item label="完成说明" name="note">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="图片链接"
|
||||
name="imageUrls"
|
||||
extra="一期先支持粘贴图片链接,每行一个;后续再接真实上传。"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="https://..." />
|
||||
<Form.Item label="验收图片" required>
|
||||
<ImageUpload
|
||||
scene="worker-acceptance"
|
||||
scope="worker"
|
||||
value={acceptanceFiles}
|
||||
onChange={setAcceptanceFiles}
|
||||
maxCount={6}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -142,6 +188,18 @@ export default function WorkerOrdersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||
}
|
||||
|
||||
function getAcceptanceImageUrls(row: WorkOrder): string[] {
|
||||
return Array.isArray(row.acceptance?.imageUrls)
|
||||
? row.acceptance.imageUrls
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function formatMoney(value: number | undefined) {
|
||||
return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkProductRule,
|
||||
WorkerLevel,
|
||||
WorkerListResponse,
|
||||
WorkerUser,
|
||||
@@ -16,7 +18,27 @@ export function fetchAdminWorkerPlatformSummary() {
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerLevels() {
|
||||
return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels')
|
||||
return apiGet<{ items: WorkerLevel[] }>(
|
||||
'/api/v1/admin/worker-platform/levels',
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkCategories() {
|
||||
return apiGet<{ items: WorkCategory[] }>(
|
||||
'/api/v1/admin/worker-platform/categories',
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkCategory(payload: {
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
status?: string
|
||||
}) {
|
||||
return apiPost<{ category: WorkCategory }>(
|
||||
'/api/v1/admin/worker-platform/categories',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkerLevel(payload: {
|
||||
@@ -25,18 +47,57 @@ export function saveAdminWorkerLevel(payload: {
|
||||
depositFreeAmount?: number
|
||||
maxActiveOrders?: number
|
||||
}) {
|
||||
return apiPost<{ level: WorkerLevel }>('/api/v1/admin/worker-platform/levels', payload)
|
||||
return apiPost<{ level: WorkerLevel }>(
|
||||
'/api/v1/admin/worker-platform/levels',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerUsers(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkerUser>>('/api/v1/admin/worker-platform/workers', params)
|
||||
return apiGet<WorkerListResponse<WorkerUser>>(
|
||||
'/api/v1/admin/worker-platform/workers',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkProductRules(params?: Record<string, unknown>) {
|
||||
return apiGet<{ items: WorkProductRule[] }>(
|
||||
'/api/v1/admin/worker-platform/product-rules',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkProductRule(payload: {
|
||||
ruleKey: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
matchType?: string
|
||||
categoryId?: number
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
rewardAmount?: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
fieldsText?: string
|
||||
sortOrder?: number
|
||||
}) {
|
||||
return apiPost<{ rule: WorkProductRule }>(
|
||||
'/api/v1/admin/worker-platform/product-rules',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function reviewAdminWorkerUser(
|
||||
workerId: number,
|
||||
payload: { status: string; reviewNote?: string; levelId?: number },
|
||||
) {
|
||||
return apiPost<{ worker: WorkerUser }>(`/api/v1/admin/worker-platform/workers/${workerId}/review`, payload)
|
||||
return apiPost<{ worker: WorkerUser }>(
|
||||
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function creditAdminWorkerWallet(
|
||||
@@ -50,7 +111,10 @@ export function creditAdminWorkerWallet(
|
||||
}
|
||||
|
||||
export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/admin/worker-platform/orders', params)
|
||||
return apiGet<WorkerListResponse<WorkOrder>>(
|
||||
'/api/v1/admin/worker-platform/orders',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminMockWorkOrder(payload: {
|
||||
@@ -59,17 +123,49 @@ export function createAdminMockWorkOrder(payload: {
|
||||
rewardAmount?: number
|
||||
materialComplete?: boolean
|
||||
}) {
|
||||
return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
'/api/v1/admin/worker-platform/orders/mock',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function syncAdminWorkerOrdersFromSource(payload: {
|
||||
platformOrderId?: string
|
||||
orderId?: number
|
||||
}) {
|
||||
return apiPost<{
|
||||
created: WorkOrder[]
|
||||
skipped: Array<{ orderItemId: number; reason: string }>
|
||||
createdCount: number
|
||||
skippedCount: number
|
||||
}>('/api/v1/admin/worker-platform/source-orders/sync', payload)
|
||||
}
|
||||
|
||||
export function submitAdminWorkOrderMaterial(
|
||||
workOrderId: number,
|
||||
fields: Record<string, string>,
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder; complete: boolean }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/material`,
|
||||
{ fields },
|
||||
)
|
||||
}
|
||||
|
||||
export function publishAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`,
|
||||
)
|
||||
}
|
||||
|
||||
export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`, { note })
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`,
|
||||
{ note },
|
||||
)
|
||||
}
|
||||
|
||||
export function acceptAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiPostForm } from '@/lib/http'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { optimizeImageForUpload } from '@/utils/image-upload'
|
||||
|
||||
type UploadScope = 'admin' | 'worker'
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
scene: string,
|
||||
scope: UploadScope,
|
||||
) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const endpoint =
|
||||
scope === 'admin'
|
||||
? '/api/v1/admin/files/upload'
|
||||
: '/api/v1/worker/files/upload'
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
||||
return response.data.file
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkerListResponse,
|
||||
WorkerLoginResponse,
|
||||
@@ -13,7 +14,10 @@ export function registerWorker(payload: {
|
||||
displayName?: string
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>('/api/v1/worker/auth/register', payload)
|
||||
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>(
|
||||
'/api/v1/worker/auth/register',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function loginWorker(payload: { username: string; password: string }) {
|
||||
@@ -21,7 +25,11 @@ export function loginWorker(payload: { username: string; password: string }) {
|
||||
}
|
||||
|
||||
export function fetchWorkerSession() {
|
||||
return apiGet<{ authenticated: boolean; expiresAt: string; worker: WorkerUser }>('/api/v1/worker/auth/session')
|
||||
return apiGet<{
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
worker: WorkerUser
|
||||
}>('/api/v1/worker/auth/session')
|
||||
}
|
||||
|
||||
export function logoutWorker() {
|
||||
@@ -29,15 +37,22 @@ export function logoutWorker() {
|
||||
}
|
||||
|
||||
export function fetchWorkerProfile() {
|
||||
return apiGet<{ worker: WorkerUser; permissions: Record<string, unknown> }>('/api/v1/worker/profile')
|
||||
return apiGet<{ worker: WorkerUser; permissions: Record<string, unknown> }>(
|
||||
'/api/v1/worker/profile',
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchWorkerHallOrders(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/hall/orders', params)
|
||||
return apiGet<WorkerListResponse<WorkOrder>>(
|
||||
'/api/v1/worker/hall/orders',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function grabWorkerOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/hall/orders/${workOrderId}/grab`,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
||||
@@ -46,14 +61,20 @@ export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
||||
|
||||
export function submitWorkerAcceptance(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[] },
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/submit-acceptance`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function lookupCollectOrder(orderNo: string) {
|
||||
return apiPost<{
|
||||
order: Pick<WorkOrder, 'workOrderNo' | 'platformOrderId' | 'productName' | 'status'>
|
||||
order: Pick<
|
||||
WorkOrder,
|
||||
'workOrderNo' | 'platformOrderId' | 'productName' | 'status'
|
||||
>
|
||||
fields: CollectField[]
|
||||
}>('/api/v1/collect/lookup', { orderNo })
|
||||
}
|
||||
@@ -62,5 +83,8 @@ export function submitCollectOrder(payload: {
|
||||
orderNo: string
|
||||
fields: Record<string, string>
|
||||
}) {
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>('/api/v1/collect/submit', payload)
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
|
||||
'/api/v1/collect/submit',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -657,7 +657,8 @@ select {
|
||||
|
||||
.payload-text {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
word-break: break-all;
|
||||
@@ -1272,7 +1273,9 @@ select {
|
||||
}
|
||||
|
||||
.platform-shop-row {
|
||||
grid-template-columns: auto auto minmax(120px, 0.9fr) minmax(140px, 1fr) minmax(220px, 1.5fr) auto auto;
|
||||
grid-template-columns:
|
||||
auto auto minmax(120px, 0.9fr) minmax(140px, 1fr)
|
||||
minmax(220px, 1.5fr) auto auto;
|
||||
}
|
||||
|
||||
.platform-rule-head {
|
||||
@@ -1337,7 +1340,9 @@ select {
|
||||
|
||||
.cloud-records-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto;
|
||||
grid-template-columns:
|
||||
minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr)
|
||||
auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1682,6 +1687,47 @@ select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.image-upload-trigger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 72px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-upload-trigger span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-preview-item {
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.worker-rule-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.worker-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.worker-page-head {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -35,6 +35,50 @@ export type WorkerLevel = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkCategory = {
|
||||
categoryId: number
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkProductRule = {
|
||||
ruleId: number
|
||||
ruleKey: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
productName: string
|
||||
matchType: 'contains' | 'exact' | string
|
||||
categoryId: number | null
|
||||
categoryName: string
|
||||
enabled: boolean
|
||||
autoCreate: boolean
|
||||
rewardAmount: number
|
||||
requiredDepositAmount: number
|
||||
depositThresholdAmount: number
|
||||
requirement: {
|
||||
fields: CollectField[]
|
||||
}
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UploadedFile = {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type WorkOrderStatus =
|
||||
| 'pending_material'
|
||||
| 'unassigned'
|
||||
@@ -59,7 +103,13 @@ export type WorkOrder = {
|
||||
freezeDepositAmount?: number
|
||||
material: Record<string, unknown>
|
||||
requirement: Record<string, unknown>
|
||||
acceptance: Record<string, unknown>
|
||||
acceptance: {
|
||||
note?: string
|
||||
files?: UploadedFile[]
|
||||
imageUrls?: string[]
|
||||
submittedAt?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
problemNote: string
|
||||
worker: null | {
|
||||
workerId: number
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
'worker-acceptance': 1920,
|
||||
'collect-material': 1920,
|
||||
avatar: 512,
|
||||
}
|
||||
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
'worker-acceptance': 0.86,
|
||||
'collect-material': 0.86,
|
||||
avatar: 0.82,
|
||||
}
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type))
|
||||
return file
|
||||
if (typeof document === 'undefined') return file
|
||||
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.84
|
||||
|
||||
try {
|
||||
const image = await loadImage(file)
|
||||
const { width, height } = fitSize(
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
maxSide,
|
||||
)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, 'webp'), {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function fitSize(width: number, height: number, maxSide: number) {
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: string,
|
||||
quality: number,
|
||||
) {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
return `${base || 'image'}.${ext}`
|
||||
}
|
||||
+21
-1
@@ -47,7 +47,7 @@ print_usage() {
|
||||
|
||||
注意:
|
||||
--reset-db 会删除本机开发库里的订单、任务、后台用户、审计日志和迁移记录。
|
||||
它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 node_modules 缓存 volume。
|
||||
它不会删除 apps/backend/data 下的平台 JSON 配置、MinIO 文件数据和 node_modules 缓存 volume。
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ read_env_value() {
|
||||
' "${ENV_FILE}"
|
||||
}
|
||||
|
||||
read_effective_env_value() {
|
||||
local key="$1"
|
||||
local default_value="$2"
|
||||
local value="${!key:-}"
|
||||
|
||||
if [[ -z "${value}" ]]; then
|
||||
value="$(read_env_value "${key}" || true)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "${value:-${default_value}}"
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先安装并启动 Docker Desktop for Mac。"
|
||||
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请升级 Docker Desktop。"
|
||||
@@ -270,6 +282,7 @@ start_dev_stack() {
|
||||
log "启动 Mac 本机 Docker 开发环境"
|
||||
if "${COMPOSE_CMD[@]}" up -d --build; then
|
||||
wait_for_service postgres "PostgreSQL"
|
||||
wait_for_service minio "MinIO"
|
||||
wait_for_service backend "后端"
|
||||
wait_for_service frontend "前端"
|
||||
wait_for_service web "Web 入口"
|
||||
@@ -327,6 +340,9 @@ print_diagnostics() {
|
||||
warn "前端最近日志:"
|
||||
"${COMPOSE_CMD[@]}" logs --tail=80 frontend || true
|
||||
|
||||
warn "MinIO 最近日志:"
|
||||
"${COMPOSE_CMD[@]}" logs --tail=80 minio || true
|
||||
|
||||
warn "后端 ready 接口:"
|
||||
"${COMPOSE_CMD[@]}" exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true
|
||||
|
||||
@@ -334,10 +350,14 @@ print_diagnostics() {
|
||||
}
|
||||
|
||||
print_endpoints() {
|
||||
local minio_console_port
|
||||
minio_console_port="$(read_effective_env_value MINIO_CONSOLE_PORT 19001)"
|
||||
|
||||
log "常用入口"
|
||||
printf '%s\n' " 前端、后台、领取页:http://localhost"
|
||||
printf '%s\n' " 后端健康检查:http://localhost/health"
|
||||
printf '%s\n' " 后端 ready 检查:http://localhost/health/ready"
|
||||
printf '%s\n' " MinIO 控制台:http://localhost:${minio_console_port}"
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
@@ -28,6 +28,21 @@ services:
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
image: docker.m.daocloud.io/minio/minio:latest
|
||||
restart: unless-stopped
|
||||
logging: *json-log-rotation
|
||||
command: server /data --address ":9000" --console-address ":9001"
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin}
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-19000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-19001}:9001"
|
||||
volumes:
|
||||
- minio_dev_data:/data
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
@@ -43,6 +58,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"]
|
||||
@@ -59,6 +76,12 @@ services:
|
||||
DATA_ROOT: /app/data
|
||||
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
|
||||
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
|
||||
STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000}
|
||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site}
|
||||
STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin}
|
||||
STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin}
|
||||
STORAGE_REGION: ${STORAGE_REGION:-us-east-1}
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10}
|
||||
# ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||
logging: *json-log-rotation
|
||||
@@ -105,5 +128,6 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_dev_data:
|
||||
minio_dev_data:
|
||||
backend_node_modules:
|
||||
frontend_node_modules:
|
||||
|
||||
@@ -56,6 +56,12 @@ services:
|
||||
DATA_ROOT: /app/data
|
||||
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
|
||||
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
|
||||
STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000}
|
||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site}
|
||||
STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin}
|
||||
STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin}
|
||||
STORAGE_REGION: ${STORAGE_REGION:-us-east-1}
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10}
|
||||
# ADMIN_SESSION_SECRET / ADMIN_DEFAULT_USERS_JSON 仅走 env_file。
|
||||
# 勿在此处用 ${ADMIN_DEFAULT_USERS_JSON} 再注入:JSON 会被 YAML 解析破坏,导致生产校验失败。
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||
@@ -63,6 +69,22 @@ services:
|
||||
volumes:
|
||||
- ./apps/backend/data:/app/data
|
||||
|
||||
minio:
|
||||
image: docker.m.daocloud.io/minio/minio:latest
|
||||
profiles: ["storage"]
|
||||
restart: unless-stopped
|
||||
logging: *json-log-rotation
|
||||
command: server /data --address ":9000" --console-address ":9001"
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
@@ -89,5 +111,6 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
Reference in New Issue
Block a user