完善接单平台上传与物品规则

This commit is contained in:
yml2213
2026-07-25 15:56:18 +08:00
parent f6d3f00f21
commit a2587eda01
40 changed files with 4978 additions and 1008 deletions
+1090 -1
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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
View File
@@ -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
}
+67 -59
View File
@@ -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',
+179 -332
View File
@@ -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
}
+169 -143
View File
@@ -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
}
+57 -5
View File
@@ -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
}
+29 -9
View File
@@ -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 || {})
+32 -30
View File
@@ -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
+37
View File
@@ -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
+141 -37
View File
@@ -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>) : {},
}),
}),
)
+36
View File
@@ -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]')
})
}
+31
View File
@@ -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
+39 -6
View File
@@ -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 {
+106 -98
View File
@@ -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>
)
}
+38 -8
View File
@@ -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`,
)
}
+22
View File
@@ -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
}
+33 -9
View 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,
)
}
+49 -3
View File
@@ -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;
+51 -1
View File
@@ -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
+86
View File
@@ -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}`
}