提交 9623d948 authored 作者: 王鹏飞's avatar 王鹏飞

refactor!: 迁移到 Fastify 5 并统一 DMS snake_case 契约

结构(fastify-cli 官方布局): - plugins/ 用 fastify-plugin 装饰共享资源(db/mysql/mongoose),生命周期随实例 - routes/ 目录即 URL 前缀,autoload 注册;DMS 用 autohooks.js 做 onRequest 鉴权 - 新增 schemas/(zod 契约)、clients/(外部系统)、lib/(纯工具)、db/schema/、models/ - 跨目录引用改用 #src/* 子路径别名 契约(破坏性): - DMS 接口的请求体/查询参数/响应字段统一 snake_case,与 MySQL 列名一致, 派生字段(can_edit_project、editable_stages、project_manager_name 等)同样下划线 - logs(Mongo) 与微信接口保持 camelCase - center-dms 需与本次同批次发布 其他: - 错误处理:4xx 记 warn(不打堆栈),5xx 记 error;统一错误响应结构 - 性能:日志索引、MySQL 连接池可配、gzip 压缩 - 运维:/health/ready 探活、x-request-id 回传、SIGTERM 优雅退出 - 依赖升级(axios/mysql2/mongoose/zod/fastify 等),生产漏洞 7 → 0
上级 6fcb7409
This source diff could not be displayed because it is too large. You can view the blob instead.
{ {
"name": "ezijing-node-server", "name": "ezijing-node-server",
"version": "2.0.0", "version": "2.0.0",
"description": "ezijing-node-server - Modern Node.js API server", "description": "ezijing-node-server - Fastify API server",
"private": true,
"type": "module", "type": "module",
"main": "src/index.js", "repository": {
"type": "git",
"url": "http://172.16.3.193/ezijing-web/ezijing-node-server.git"
},
"imports": {
"#src/*": "./src/*"
},
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "cross-env NODE_ENV=development node --watch src/index.js", "dev": "cross-env NODE_ENV=development node --watch src/index.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"lint": "eslint src/", "lint": "eslint .",
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix .",
"deploy": "cross-env NODE_ENV=production SERVER_PORT=5101 DOTENV_CONFIG_PATH=.env.prod pm2 start src/index.js -n ezijing-node-server", "deploy": "NODE_ENV=production DOTENV_CONFIG_PATH=.env.prod pm2 start src/index.js -n ezijing-node-server --kill-timeout 10000",
"reload": "pm2 reload ezijing-node-server" "reload": "pm2 reload ezijing-node-server",
"test": "node --test",
"db:migrate": "drizzle-kit migrate",
"db:status": "node scripts/db-status.mjs",
"check": "npm run lint && npm test",
"test:watch": "node --test --watch"
}, },
"author": "King", "author": "King",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=20.19.0"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9.15.0", "eslint": "^10.10.0",
"globals": "^15.12.0", "globals": "^17.12.0"
"pino-pretty": "^13.1.3"
}, },
"dependencies": { "dependencies": {
"@fastify/autoload": "^6.5.0",
"@fastify/compress": "^9.2.0",
"@fastify/cors": "^11.3.0",
"@fastify/formbody": "^9.0.0",
"@fastify/http-proxy": "^11.6.2",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^6.1.1",
"ali-oss": "^6.23.0", "ali-oss": "^6.23.0",
"axios": "^1.15.2", "axios": "^1.20.0",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dotenv": "^16.4.5", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "fastify": "^5.12.3",
"express-http-proxy": "^2.1.2", "fastify-plugin": "^6.0.0",
"mongoose": "^8.23.1", "fastify-type-provider-zod": "^7.0.0",
"lru-cache": "^11.5.2",
"mongoose": "^9.9.5",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-http": "^11.0.0",
"ua-parser-js": "^2.0.6", "ua-parser-js": "^2.0.6",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
......
import express from 'express' import path from 'node:path'
import cors from 'cors' import { fileURLToPath } from 'node:url'
import proxy from 'express-http-proxy' import autoload from '@fastify/autoload'
import pinoHttp from 'pino-http' import { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod'
import logger from './lib/logger.js' import { error } from './lib/response.js'
import { notFound, errorHandler } from './middleware/error.js'
import wechatRoutes from './modules/wechat/wechat.routes.js' const __dirname = path.dirname(fileURLToPath(import.meta.url))
import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js'
import logsRoutes from './modules/logs/logs.routes.js' /**
import dmsRoutes from './modules/dms/dms.routes.js' * 根插件(Fastify 官方推荐结构):
* plugins/ 共享插件,使用 fastify-plugin 包装,装饰器/编译器对全局可见
const app = express() * routes/ 路由插件,目录名即路由前缀;autohooks.js 为同级及子级路由挂钩子
*
// Trust proxy * 错误处理器在这里(根上下文)设置,并且必须在加载 routes 之前,
app.set('trust proxy', true) * 子插件在注册时继承父级的 error handler。
*/
// Request logging export default async function app(fastify, opts) {
app.use(pinoHttp({ logger, autoLogging: process.env.NODE_ENV !== 'test' })) fastify.setNotFoundHandler((request, reply) => {
return error(reply, '接口未定义', 404, { path: request.url, method: request.method })
// Middleware })
app.use(cors({ credentials: true }))
app.use('/api/usercenter', proxy('https://api-usercenter.ezijing.com')) fastify.setErrorHandler((err, request, reply) => {
// 增加请求体大小限制,支持批量日志上报(默认 100kb,增加到 10mb) if (hasZodFastifySchemaValidationErrors(err)) {
app.use(express.json({ limit: '10mb' })) const message = err.validation
app.use(express.urlencoded({ extended: true, limit: '10mb' })) .map((e) => {
const p = e.instancePath.replace(/^\//, '').replaceAll('/', '.')
// Health check return `${p ? p + ': ' : ''}${e.message}`
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now() })) })
.join('; ')
// Modules return error(reply, message, 400, { details: err.validation })
app.use(wechatRoutes) }
app.use(wxChartRoutes)
app.use('/api', logsRoutes) const candidate = Number(err.statusCode ?? err.status)
app.use('/api', dmsRoutes) const statusCode = Number.isInteger(candidate) && candidate >= 100 && candidate < 1000 ? candidate : 500
const message = process.env.NODE_ENV === 'production' && statusCode === 500 ? 'Internal Server Error' : err.message
// Error handling
app.use(notFound) if (statusCode >= 500) {
app.use(errorHandler) request.log.error({ err, url: request.url, method: request.method }, 'Request error')
} else {
export default app // 4xx 是正常业务结果(登录态失效、参数不合法等),记摘要即可,避免刷屏淹没真正的故障
request.log.warn(
{ statusCode, url: request.url, method: request.method, message: err.message },
'Request failed',
)
}
return error(reply, message, statusCode)
})
await fastify.register(autoload, {
dir: path.join(__dirname, 'plugins'),
options: { ...opts },
})
await fastify.register(autoload, {
dir: path.join(__dirname, 'routes'),
autoHooks: true,
cascadeHooks: true,
options: { ...opts },
})
}
import OSS from 'ali-oss' import OSS from 'ali-oss'
import config from '../config.js' import config from '#src/config.js'
let client let client
......
import crypto from 'node:crypto' import crypto from 'node:crypto'
import axios from 'axios' import axios from 'axios'
import config from '../../../config.js' import config from '#src/config.js'
import { createPermissionSignature } from './permission-sign.js' import { createCachedFetcher } from '#src/lib/cached-fetch.js'
import logger from '#src/lib/logger.js'
import { createPermissionSignature } from '#src/lib/sign.js'
import { getUserProfiles } from './user-center.client.js' import { getUserProfiles } from './user-center.client.js'
const cache = new Map() // 权限取数器:TTL + 容量上限 + 并发去重
const requests = new Map() const getAuthorization = createCachedFetcher({ ttlMs: config.dmsAuth.permissionCacheTtlSeconds * 1000 })
const unwrapItems = (payload) => payload?.data?.items ?? [] const unwrapItems = (payload) => payload?.data?.items ?? []
...@@ -37,33 +39,17 @@ const requestRaw = async (path, tgc, params = {}) => { ...@@ -37,33 +39,17 @@ const requestRaw = async (path, tgc, params = {}) => {
const request = async (path, tgc, params = {}) => unwrapItems(await requestRaw(path, tgc, params)) const request = async (path, tgc, params = {}) => unwrapItems(await requestRaw(path, tgc, params))
export const getUserAuthorization = async (ssoId, tgc) => { export const getUserAuthorization = (ssoId, tgc) =>
const cached = cache.get(ssoId) getAuthorization(ssoId, async () => {
if (cached && cached.expiresAt > Date.now()) return cached.value const [roles, routes] = await Promise.all([
request('/api/v1/user/roles', tgc),
const pending = requests.get(ssoId) request('/api/v1/user/routes', tgc),
if (pending) return pending ])
return {
const requestPromise = Promise.all([ roles: Array.isArray(roles) ? roles : [],
request('/api/v1/user/roles', tgc), routes: Array.isArray(routes) ? routes : [],
request('/api/v1/user/routes', tgc), }
]) })
.then(([roles, routes]) => {
const value = {
roles: Array.isArray(roles) ? roles : [],
routes: Array.isArray(routes) ? routes : [],
}
cache.set(ssoId, {
value,
expiresAt: Date.now() + config.dmsAuth.permissionCacheTtlSeconds * 1000,
})
return value
})
.finally(() => requests.delete(ssoId))
requests.set(ssoId, requestPromise)
return requestPromise
}
export const getApplicationUsers = async (tgc, params = {}) => { export const getApplicationUsers = async (tgc, params = {}) => {
const payload = await requestRaw('/api/v1/app/users', tgc, params) const payload = await requestRaw('/api/v1/app/users', tgc, params)
...@@ -75,7 +61,7 @@ export const getApplicationUsers = async (tgc, params = {}) => { ...@@ -75,7 +61,7 @@ export const getApplicationUsers = async (tgc, params = {}) => {
try { try {
profiles = await getUserProfiles(items.map((item) => item.sso_id)) profiles = await getUserProfiles(items.map((item) => item.sso_id))
} catch (error) { } catch (error) {
console.warn('用户中心批量查询失败,成员列表将保留权限中心数据', error.message) logger.warn({ err: error, count: items.length }, '用户中心批量查询失败,成员列表将保留权限中心数据')
} }
return { return {
......
import crypto from 'node:crypto' import crypto from 'node:crypto'
import axios from 'axios' import axios from 'axios'
import config from '../../../config.js' import config from '#src/config.js'
const MAX_BATCH_SIZE = 500 const MAX_BATCH_SIZE = 500
...@@ -34,7 +34,7 @@ export const getUserProfiles = async (ssoIds) => { ...@@ -34,7 +34,7 @@ export const getUserProfiles = async (ssoIds) => {
if (!ids.length) return {} if (!ids.length) return {}
if (!userCenterBatchUrl || !userCenterSalt) { if (!userCenterBatchUrl || !userCenterSalt) {
console.warn('用户中心批量查询未配置 USER_CENTER_BATCH_URL 或 USER_CENTER_SALT') logger.warn('用户中心批量查询未配置 USER_CENTER_SALT,成员列表将缺少姓名(检查 .env)')
return {} return {}
} }
......
import 'dotenv/config' import 'dotenv/config'
import fs from 'node:fs'
import path from 'path' import path from 'path'
import { z } from 'zod'
const port = (fallback) => z.coerce.number().int().min(1).max(65535).default(fallback)
const int = (fallback) => z.coerce.number().int().positive().default(fallback)
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
ENABLE_DOCS: z.string().optional(),
SERVER_PORT: port(4101),
DATA_DIR: z.string().optional(),
MONGODB_URI: z.string().default(''),
MONGO_MAX_POOL_SIZE: int(10),
DATABASE_URL: z.string().default(''),
MYSQL_HOST: z.string().default('127.0.0.1'),
MYSQL_PORT: port(3306),
MYSQL_USER: z.string().default('root'),
MYSQL_PASSWORD: z.string().default(''),
MYSQL_DATABASE: z.string().default('com_dms'),
MYSQL_POOL_SIZE: int(10),
OSS_REGION: z.string().default('oss-cn-beijing'),
OSS_BUCKET: z.string().default('webapp-pub'),
OSS_ACCESS_KEY_ID: z.string().default(''),
OSS_ACCESS_KEY_SECRET: z.string().default(''),
OSS_ENDPOINT: z.string().default(''),
CASE_JSON_OBJECT_KEY: z.string().default('case_library/case.json'),
SSO_USER_INFO_URL: z.string().default('https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info'),
USER_CENTER_BATCH_URL: z.string().default('https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info'),
USER_CENTER_SALT: z.string().default(''),
PERMISSION_API_URL: z.string().default(''),
PERMISSION_APP_ID: z.string().default(''),
PERMISSION_APP_SECRET: z.string().default(''),
SSO_USER_CACHE_TTL_SECONDS: int(180),
PERMISSION_CACHE_TTL_SECONDS: int(180),
})
// 启动即校验环境变量,配置错误立刻失败,而不是运行到某个请求时才炸
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
const details = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
throw new Error(`环境变量配置错误: ${details}`)
}
const env = parsed.data
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const config = { const config = {
env: process.env.NODE_ENV || 'development', env: env.NODE_ENV,
port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101, version: process.env.APP_VERSION || pkg.version,
dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'), docs: {
// 生产默认不暴露 /docs;需要临时查看时设 ENABLE_DOCS=true
enabled: env.ENABLE_DOCS === undefined ? env.NODE_ENV !== 'production' : env.ENABLE_DOCS === 'true',
routePrefix: '/docs',
},
port: env.SERVER_PORT,
dataDir: env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'),
mongodb: { mongodb: {
uri: process.env.MONGODB_URI || '', uri: env.MONGODB_URI,
maxPoolSize: env.MONGO_MAX_POOL_SIZE,
}, },
mysql: { mysql: {
url: process.env.DATABASE_URL || '', url: env.DATABASE_URL,
host: process.env.MYSQL_HOST || '127.0.0.1', host: env.MYSQL_HOST,
port: parseInt(process.env.MYSQL_PORT || '3306', 10) || 3306, port: env.MYSQL_PORT,
user: process.env.MYSQL_USER || 'root', user: env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD || '', password: env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE || 'com_dms', database: env.MYSQL_DATABASE,
poolSize: env.MYSQL_POOL_SIZE,
}, },
oss: { oss: {
region: process.env.OSS_REGION || 'oss-cn-beijing', region: env.OSS_REGION,
bucket: process.env.OSS_BUCKET || 'webapp-pub', bucket: env.OSS_BUCKET,
accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', accessKeyId: env.OSS_ACCESS_KEY_ID,
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', accessKeySecret: env.OSS_ACCESS_KEY_SECRET,
endpoint: process.env.OSS_ENDPOINT || '', endpoint: env.OSS_ENDPOINT,
caseJsonObjectKey: process.env.CASE_JSON_OBJECT_KEY || 'case_library/case.json', caseJsonObjectKey: env.CASE_JSON_OBJECT_KEY,
}, },
dmsAuth: { dmsAuth: {
ssoUserInfoUrl: ssoUserInfoUrl: env.SSO_USER_INFO_URL,
process.env.SSO_USER_INFO_URL || 'https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info', userCenterBatchUrl: env.USER_CENTER_BATCH_URL,
userCenterBatchUrl: userCenterSalt: env.USER_CENTER_SALT,
process.env.USER_CENTER_BATCH_URL || 'https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info', permissionApiUrl: env.PERMISSION_API_URL,
userCenterSalt: process.env.USER_CENTER_SALT || '', permissionAppId: env.PERMISSION_APP_ID,
permissionApiUrl: process.env.PERMISSION_API_URL || '', permissionAppSecret: env.PERMISSION_APP_SECRET,
permissionAppId: process.env.PERMISSION_APP_ID || '', userInfoCacheTtlSeconds: env.SSO_USER_CACHE_TTL_SECONDS,
permissionAppSecret: process.env.PERMISSION_APP_SECRET || '', permissionCacheTtlSeconds: env.PERMISSION_CACHE_TTL_SECONDS,
userInfoCacheTtlSeconds: parseInt(process.env.SSO_USER_CACHE_TTL_SECONDS || '180', 10) || 180,
permissionCacheTtlSeconds: parseInt(process.env.PERMISSION_CACHE_TTL_SECONDS || '180', 10) || 180,
}, },
wechat: { wechat: {
apps: { apps: {
......
import { drizzle } from 'drizzle-orm/mysql2' import { drizzle } from 'drizzle-orm/mysql2'
import { getMysqlPool } from '../lib/mysql.js' import { getMysqlPool } from '#src/lib/mysql.js'
export const db = drizzle(getMysqlPool()) export const db = drizzle(getMysqlPool())
import { bigint, index, longtext, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core' import { bigint, index, longtext, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js' import { timestamps } from './columns.js'
export const cases = mysqlTable( export const cases = mysqlTable(
'case_list', 'case_list',
...@@ -7,15 +7,15 @@ export const cases = mysqlTable( ...@@ -7,15 +7,15 @@ export const cases = mysqlTable(
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
description: text('description'), description: text('description'),
productId: bigint('product_id', { mode: 'number', unsigned: true }), product_id: bigint('product_id', { mode: 'number', unsigned: true }),
productName: varchar('product_name', { length: 255 }), product_name: varchar('product_name', { length: 255 }),
files: longtext('files'), files: longtext('files'),
operatorUserId: varchar('operator_user_id', { length: 64 }), operator_user_id: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }), operator_name: varchar('operator_name', { length: 120 }),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
productIdIdx: index('idx_cases_product_id').on(table.productId), productIdIdx: index('idx_cases_product_id').on(table.product_id),
operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operatorUserId), operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operator_user_id),
}) })
) )
...@@ -2,8 +2,8 @@ import { datetime } from 'drizzle-orm/mysql-core' ...@@ -2,8 +2,8 @@ import { datetime } from 'drizzle-orm/mysql-core'
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
export const timestamps = { export const timestamps = {
createdAt: datetime('created_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`), created_at: datetime('created_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at', { mode: 'string' }) updated_at: datetime('updated_at', { mode: 'string' })
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
} }
import { bigint, index, int, mysqlTable, text, uniqueIndex, varchar } from 'drizzle-orm/mysql-core' import { bigint, index, int, mysqlTable, text, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js' import { timestamps } from './columns.js'
export const PRODUCT_STATUSES = { export const PRODUCT_STATUSES = {
DISABLED: 0, DISABLED: 0,
...@@ -13,13 +13,13 @@ export const products = mysqlTable( ...@@ -13,13 +13,13 @@ export const products = mysqlTable(
name: varchar('name', { length: 120 }).notNull(), name: varchar('name', { length: 120 }).notNull(),
status: int('status').notNull().default(PRODUCT_STATUSES.ENABLED), status: int('status').notNull().default(PRODUCT_STATUSES.ENABLED),
description: text('description'), description: text('description'),
operatorUserId: varchar('operator_user_id', { length: 64 }), operator_user_id: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }), operator_name: varchar('operator_name', { length: 120 }),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
nameIdx: uniqueIndex('uk_products_name').on(table.name), nameIdx: uniqueIndex('uk_products_name').on(table.name),
statusIdx: index('idx_products_status').on(table.status), statusIdx: index('idx_products_status').on(table.status),
operatorUserIdIdx: index('idx_products_operator_user_id').on(table.operatorUserId), operatorUserIdIdx: index('idx_products_operator_user_id').on(table.operator_user_id),
}) })
) )
import { bigint, datetime, index, mysqlTable, tinyint, uniqueIndex, varchar } from 'drizzle-orm/mysql-core' import { bigint, datetime, index, mysqlTable, tinyint, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { timestamps } from '../db/columns.js' import { timestamps } from './columns.js'
import { projects } from '../projects/schema.js' import { projects } from './projects.js'
export const PROJECT_ROLES = [ export const PROJECT_ROLES = [
'project_manager', 'project_manager',
...@@ -16,20 +16,20 @@ export const PROJECT_ROLES = [ ...@@ -16,20 +16,20 @@ export const PROJECT_ROLES = [
export const projectRoleAssignments = mysqlTable('project_role_assignments', { export const projectRoleAssignments = mysqlTable('project_role_assignments', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
ssoId: varchar('sso_id', { length: 64 }).notNull(), sso_id: varchar('sso_id', { length: 64 }).notNull(),
userName: varchar('user_name', { length: 120 }), user_name: varchar('user_name', { length: 120 }),
mobile: varchar('mobile', { length: 64 }), mobile: varchar('mobile', { length: 64 }),
role: varchar('role', { length: 32 }).notNull(), role: varchar('role', { length: 32 }).notNull(),
status: tinyint('status', { unsigned: true }).notNull().default(1), status: tinyint('status', { unsigned: true }).notNull().default(1),
assignedBySsoId: varchar('assigned_by_sso_id', { length: 64 }), assigned_by_sso_id: varchar('assigned_by_sso_id', { length: 64 }),
assignedAt: datetime('assigned_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`), assigned_at: datetime('assigned_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
...timestamps, ...timestamps,
}, (table) => ({ }, (table) => ({
projectIdx: index('idx_project_roles_project_id').on(table.projectId), projectIdx: index('idx_project_roles_project_id').on(table.project_id),
userIdx: index('idx_project_roles_sso_id').on(table.ssoId), userIdx: index('idx_project_roles_sso_id').on(table.sso_id),
projectRoleIdx: index('idx_project_roles_project_role').on(table.projectId, table.role), projectRoleIdx: index('idx_project_roles_project_role').on(table.project_id, table.role),
assignmentIdx: uniqueIndex('uk_project_roles_assignment').on(table.projectId, table.ssoId, table.role), assignmentIdx: uniqueIndex('uk_project_roles_assignment').on(table.project_id, table.sso_id, table.role),
})) }))
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
uniqueIndex, uniqueIndex,
varchar, varchar,
} from 'drizzle-orm/mysql-core' } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js' import { timestamps } from './columns.js'
export const PROJECT_STAGES = { export const PROJECT_STAGES = {
SOLUTION: 10, SOLUTION: 10,
...@@ -30,30 +30,30 @@ export const projects = mysqlTable( ...@@ -30,30 +30,30 @@ export const projects = mysqlTable(
'project_list', 'project_list',
{ {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectCode: varchar('project_code', { length: 64 }).notNull(), project_code: varchar('project_code', { length: 64 }).notNull(),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
province: varchar('province', { length: 120 }), province: varchar('province', { length: 120 }),
city: varchar('city', { length: 120 }), city: varchar('city', { length: 120 }),
schoolName: varchar('school_name', { length: 255 }), school_name: varchar('school_name', { length: 255 }),
departmentName: varchar('department_name', { length: 255 }), department_name: varchar('department_name', { length: 255 }),
productId: bigint('product_id', { mode: 'number', unsigned: true }), product_id: bigint('product_id', { mode: 'number', unsigned: true }),
productName: varchar('product_name', { length: 255 }), product_name: varchar('product_name', { length: 255 }),
contactName: varchar('contact_name', { length: 120 }), contact_name: varchar('contact_name', { length: 120 }),
contactTitle: varchar('contact_title', { length: 120 }), contact_title: varchar('contact_title', { length: 120 }),
contactPhone: varchar('contact_phone', { length: 64 }), contact_phone: varchar('contact_phone', { length: 64 }),
solution: text('solution'), solution: text('solution'),
stage: int('stage').notNull().default(PROJECT_STAGES.SOLUTION), stage: int('stage').notNull().default(PROJECT_STAGES.SOLUTION),
status: int('status').notNull().default(PROJECT_STATUSES.ACTIVE), status: int('status').notNull().default(PROJECT_STATUSES.ACTIVE),
description: text('description'), description: text('description'),
operatorUserId: varchar('operator_user_id', { length: 64 }), operator_user_id: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }), operator_name: varchar('operator_name', { length: 120 }),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.projectCode), projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.project_code),
stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status), stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status),
productIdIdx: index('idx_projects_product_id').on(table.productId), productIdIdx: index('idx_projects_product_id').on(table.product_id),
operatorUserIdIdx: index('idx_projects_operator_user_id').on(table.operatorUserId), operatorUserIdIdx: index('idx_projects_operator_user_id').on(table.operator_user_id),
}) })
) )
...@@ -61,19 +61,19 @@ export const initiations = mysqlTable( ...@@ -61,19 +61,19 @@ export const initiations = mysqlTable(
'project_initiations', 'project_initiations',
{ {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
applicationFileUrl: text('application_file_url'), application_file_url: text('application_file_url'),
argumentFileUrl: text('argument_file_url'), argument_file_url: text('argument_file_url'),
projectAmount: decimal('project_amount', { precision: 14, scale: 2 }), project_amount: decimal('project_amount', { precision: 14, scale: 2 }),
fundSource: varchar('fund_source', { length: 255 }), fund_source: varchar('fund_source', { length: 255 }),
executionPlan: text('execution_plan'), execution_plan: text('execution_plan'),
expectedTenderTime: varchar('expected_tender_time', { length: 120 }), expected_tender_time: varchar('expected_tender_time', { length: 120 }),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
projectIdIdx: uniqueIndex('uk_initiations_project_id').on(table.projectId), projectIdIdx: uniqueIndex('uk_initiations_project_id').on(table.project_id),
}) })
) )
...@@ -81,22 +81,22 @@ export const procurements = mysqlTable( ...@@ -81,22 +81,22 @@ export const procurements = mysqlTable(
'project_procurements', 'project_procurements',
{ {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
tenderOwner: varchar('tender_owner', { length: 120 }), tender_owner: varchar('tender_owner', { length: 120 }),
businessParams: text('business_params'), business_params: text('business_params'),
technicalParams: text('technical_params'), technical_params: text('technical_params'),
intentionPublicity: varchar('intention_publicity', { length: 255 }), intention_publicity: varchar('intention_publicity', { length: 255 }),
mainBidOwner: varchar('main_bid_owner', { length: 120 }), main_bid_owner: varchar('main_bid_owner', { length: 120 }),
companionBidders: text('companion_bidders'), companion_bidders: text('companion_bidders'),
formalBidStatus: varchar('formal_bid_status', { length: 120 }), formal_bid_status: varchar('formal_bid_status', { length: 120 }),
winningNoticeFileUrl: text('winning_notice_file_url'), winning_notice_file_url: text('winning_notice_file_url'),
bidArchiveFileUrl: text('bid_archive_file_url'), bid_archive_file_url: text('bid_archive_file_url'),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
projectIdIdx: uniqueIndex('uk_procurements_project_id').on(table.projectId), projectIdIdx: uniqueIndex('uk_procurements_project_id').on(table.project_id),
}) })
) )
...@@ -104,67 +104,67 @@ export const contracts = mysqlTable( ...@@ -104,67 +104,67 @@ export const contracts = mysqlTable(
'project_contracts', 'project_contracts',
{ {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
contractNo: varchar('contract_no', { length: 128 }), contract_no: varchar('contract_no', { length: 128 }),
contractName: varchar('contract_name', { length: 255 }), contract_name: varchar('contract_name', { length: 255 }),
amount: decimal('amount', { precision: 14, scale: 2 }), amount: decimal('amount', { precision: 14, scale: 2 }),
drafter: varchar('drafter', { length: 120 }), drafter: varchar('drafter', { length: 120 }),
archiveFileUrl: text('archive_file_url'), archive_file_url: text('archive_file_url'),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
projectIdIdx: uniqueIndex('uk_contracts_project_id').on(table.projectId), projectIdIdx: uniqueIndex('uk_contracts_project_id').on(table.project_id),
}) })
) )
export const deliveries = mysqlTable('project_deliveries', { export const deliveries = mysqlTable('project_deliveries', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
deliveryAddress: varchar('delivery_address', { length: 255 }), delivery_address: varchar('delivery_address', { length: 255 }),
deliveryContact: varchar('delivery_contact', { length: 120 }), delivery_contact: varchar('delivery_contact', { length: 120 }),
deliveryContactPhone: varchar('delivery_contact_phone', { length: 64 }), delivery_contact_phone: varchar('delivery_contact_phone', { length: 64 }),
deliveryNote: text('delivery_note'), delivery_note: text('delivery_note'),
completedAt: datetime('completed_at', { mode: 'string' }), completed_at: datetime('completed_at', { mode: 'string' }),
...timestamps, ...timestamps,
}, (table) => ({ }, (table) => ({
projectIdIdx: uniqueIndex('uk_deliveries_project_id').on(table.projectId), projectIdIdx: uniqueIndex('uk_deliveries_project_id').on(table.project_id),
})) }))
export const acceptances = mysqlTable('project_acceptances', { export const acceptances = mysqlTable('project_acceptances', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
acceptanceReportUrl: text('acceptance_report_url'), acceptance_report_url: text('acceptance_report_url'),
acceptanceNote: text('acceptance_note'), acceptance_note: text('acceptance_note'),
...timestamps, ...timestamps,
}, (table) => ({ }, (table) => ({
projectIdIdx: uniqueIndex('uk_acceptances_project_id').on(table.projectId), projectIdIdx: uniqueIndex('uk_acceptances_project_id').on(table.project_id),
})) }))
export const projectTimelines = mysqlTable( export const projectTimelines = mysqlTable(
'project_timelines', 'project_timelines',
{ {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(), id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true }) project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
projectCode: varchar('project_code', { length: 64 }).notNull(), project_code: varchar('project_code', { length: 64 }).notNull(),
title: varchar('title', { length: 255 }).notNull(), title: varchar('title', { length: 255 }).notNull(),
description: text('description'), description: text('description'),
stage: int('stage').notNull(), stage: int('stage').notNull(),
status: int('status').notNull(), status: int('status').notNull(),
operatorUserId: varchar('operator_user_id', { length: 64 }), operator_user_id: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }), operator_name: varchar('operator_name', { length: 120 }),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
projectIdIdx: index('idx_project_timelines_project_id').on(table.projectId), projectIdIdx: index('idx_project_timelines_project_id').on(table.project_id),
projectCodeIdx: index('idx_project_timelines_project_code').on(table.projectCode), projectCodeIdx: index('idx_project_timelines_project_code').on(table.project_code),
createdAtIdx: index('idx_project_timelines_created_at').on(table.createdAt), createdAtIdx: index('idx_project_timelines_created_at').on(table.created_at),
}) })
) )
import app from './app.js' import Fastify from 'fastify'
import config from './config.js' import config from './config.js'
import logger from './lib/logger.js' import logger from './lib/logger.js'
import { connectMongo, disconnectMongo } from './lib/mongo.js' import app from './app.js'
import { closeMysqlPool } from './lib/mysql.js'
/**
* 进程入口:创建 Fastify 实例,挂载根插件(src/app.js),监听端口。
* 所有资源的生命周期(Mongo/MySQL 连接等)由各自的 plugin 通过 onClose 管理,
* 这里只负责 listen 和信号处理。
*/
const start = async () => { const start = async () => {
try { const fastify = Fastify({
await connectMongo() loggerInstance: logger,
trustProxy: true,
const server = app.listen(config.port, () => { // 增加请求体大小限制,支持批量日志上报(默认 1mb,增加到 10mb)
logger.info({ port: config.port, env: config.env }, 'Server started 🚀') bodyLimit: 10 * 1024 * 1024,
}) })
// Graceful shutdown try {
const shutdown = async (signal) => { await fastify.register(app)
logger.info(`${signal} received, shutting down...`) await fastify.listen({ port: config.port, host: '0.0.0.0' })
server.close(async () => { logger.info({ port: config.port, env: config.env }, 'Server started 🚀')
await disconnectMongo()
await closeMysqlPool()
logger.info('Server closed')
process.exit(0)
})
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Failed to start server') logger.error({ err: error }, 'Failed to start server')
process.exit(1) process.exit(1)
} }
// Graceful shutdown
const shutdown = async (signal) => {
logger.info(`${signal} received, shutting down...`)
await fastify.close()
logger.info('Server closed')
process.exit(0)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
} }
process.on('unhandledRejection', (err) => logger.error({ err }, 'Unhandled Rejection')) process.on('unhandledRejection', (err) => logger.error({ err }, 'Unhandled Rejection'))
......
import { LRUCache } from 'lru-cache'
/**
* 「缓存 + 在途请求去重」取数器。
*
* 同一个 key 的并发调用只会真正执行一次 fetcher,其余复用同一个 Promise;
* 结果写入 LRU 缓存(TTL + 容量上限),后续命中直接返回。
*
* const getUser = createCachedFetcher({ ttlMs: 180_000 })
* const user = await getUser(tgc, () => fetchUser(tgc))
*/
export const createCachedFetcher = ({ ttlMs, maxSize = 5000 }) => {
const cache = new LRUCache({ max: maxSize, ttl: ttlMs })
const inFlight = new Map()
return async (key, fetcher) => {
const cached = cache.get(key)
if (cached !== undefined) return cached
const pending = inFlight.get(key)
if (pending) return pending
const promise = fetcher()
.then((value) => {
cache.set(key, value)
return value
})
.finally(() => inFlight.delete(key))
inFlight.set(key, promise)
return promise
}
}
...@@ -3,7 +3,7 @@ import { existsSync } from 'fs' ...@@ -3,7 +3,7 @@ import { existsSync } from 'fs'
import path from 'path' import path from 'path'
import logger from './logger.js' import logger from './logger.js'
export const ensureDir = async (filePath) => { const ensureDir = async (filePath) => {
const dir = path.dirname(filePath) const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
} }
......
import pino from 'pino' import pino from 'pino'
// 统一输出结构化 JSON(便于 K8s 日志采集);本地排查可用 LOG_LEVEL=debug
const logger = pino({ const logger = pino({
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'), level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' } }
: undefined,
}) })
export default logger export default logger
import mongoose from 'mongoose' import mongoose from 'mongoose'
import config from '../config.js' import config from '#src/config.js'
import logger from './logger.js' import logger from './logger.js'
let isConnected = false let isConnected = false
...@@ -12,7 +12,10 @@ export const connectMongo = async () => { ...@@ -12,7 +12,10 @@ export const connectMongo = async () => {
} }
try { try {
await mongoose.connect(config.mongodb.uri) await mongoose.connect(config.mongodb.uri, {
maxPoolSize: config.mongodb.maxPoolSize,
serverSelectionTimeoutMS: 5000,
})
isConnected = true isConnected = true
logger.info({ uri: config.mongodb.uri.replace(/\/\/.*@/, '//*****@') }, 'MongoDB connected') logger.info({ uri: config.mongodb.uri.replace(/\/\/.*@/, '//*****@') }, 'MongoDB connected')
} catch (error) { } catch (error) {
......
import mysql from 'mysql2/promise' import mysql from 'mysql2/promise'
import config from '../config.js' import config from '#src/config.js'
let pool let pool
...@@ -16,8 +16,13 @@ export const getMysqlPool = () => { ...@@ -16,8 +16,13 @@ export const getMysqlPool = () => {
pool = mysql.createPool({ pool = mysql.createPool({
...connectionOptions, ...connectionOptions,
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: config.mysql.poolSize,
maxIdle: config.mysql.poolSize,
idleTimeout: 60_000,
queueLimit: 0, queueLimit: 0,
connectTimeout: 10_000,
enableKeepAlive: true,
keepAliveInitialDelay: 10_000,
supportBigNumbers: true, supportBigNumbers: true,
bigNumberStrings: true, bigNumberStrings: true,
}) })
......
/** /**
* 统一响应格式工具 * 统一响应格式工具(Fastify reply)
*/ */
export const success = (res, data, status = 200) => { export const success = (reply, data, status = 200) => {
res.status(status).json({ success: true, data }) return reply.code(status).send({ success: true, data })
} }
export const error = (res, message, status = 400, extra = {}) => { export const error = (reply, message, status = 400, extra = {}) => {
res.status(status).json({ success: false, error: { message, ...extra } }) return reply.code(status).send({ success: false, error: { message, ...extra } })
} }
import { ZodError } from 'zod'
import { httpError } from './http-error.js'
/**
* Express middleware factory — validates req.body / req.query / req.params
* against Zod schemas. Parsed body & params replace the originals for
* downstream type safety. Query is validated but not reassigned (Express 5
* makes req.query getter-only).
*/
export const validate = (schemas) => (req, _res, next) => {
try {
if (schemas.body) req.body = schemas.body.parse(req.body)
if (schemas.query) schemas.query.parse(req.query) // validate only
if (schemas.params) req.params = schemas.params.parse(req.params)
next()
} catch (err) {
if (err instanceof ZodError) {
const message = err.issues
.map((e) => `${e.path.length ? e.path.join('.') + ': ' : ''}${e.message}`)
.join('; ')
return next(httpError(400, message, { details: err.issues }))
}
next(err)
}
}
import logger from '../lib/logger.js'
import { error } from '../lib/response.js'
export const notFound = (req, res) => {
error(res, '接口未定义', 404, { path: req.path, method: req.method })
}
export const errorHandler = (err, req, res, _next) => {
const candidate = Number(err.statusCode ?? err.status)
const statusCode = Number.isInteger(candidate) && candidate >= 100 && candidate < 1000 ? candidate : 500
const message = process.env.NODE_ENV === 'production' && statusCode === 500 ? 'Internal Server Error' : err.message
logger.error({ err, url: req.url, method: req.method }, 'Request error')
error(res, message, statusCode)
}
...@@ -107,19 +107,18 @@ const logSchema = new mongoose.Schema( ...@@ -107,19 +107,18 @@ const logSchema = new mongoose.Schema(
} }
) )
// 存储前自动截断大数据 // 存储前自动截断大数据(mongoose 9 起中间件不再接收 next(),一律用 async)
logSchema.pre('save', function (next) { logSchema.pre('save', async function () {
if (this.api?.request?.data !== undefined) { if (this.api?.request?.data !== undefined) {
this.api.request = truncateBody(this.api.request.data) this.api.request = truncateBody(this.api.request.data)
} }
if (this.api?.response?.data !== undefined) { if (this.api?.response?.data !== undefined) {
this.api.response = truncateBody(this.api.response.data) this.api.response = truncateBody(this.api.response.data)
} }
next()
}) })
// 批量插入时也处理 // 批量插入时也处理(kareem 3 直接 apply 参数,docs 是第一个参数)
logSchema.pre('insertMany', function (next, docs) { logSchema.pre('insertMany', async function (docs) {
docs.forEach((doc) => { docs.forEach((doc) => {
if (doc.api?.request?.data !== undefined) { if (doc.api?.request?.data !== undefined) {
doc.api.request = truncateBody(doc.api.request.data) doc.api.request = truncateBody(doc.api.request.data)
...@@ -128,7 +127,6 @@ logSchema.pre('insertMany', function (next, docs) { ...@@ -128,7 +127,6 @@ logSchema.pre('insertMany', function (next, docs) {
doc.api.response = truncateBody(doc.api.response.data) doc.api.response = truncateBody(doc.api.response.data)
} }
}) })
next()
}) })
// 注意:TTL 索引需要 Date 类型,但 createdAt 现在是字符串类型 // 注意:TTL 索引需要 Date 类型,但 createdAt 现在是字符串类型
...@@ -138,5 +136,9 @@ logSchema.pre('insertMany', function (next, docs) { ...@@ -138,5 +136,9 @@ logSchema.pre('insertMany', function (next, docs) {
// Compound index for common queries // Compound index for common queries
logSchema.index({ type: 1, appName: 1, createdAt: -1 }) logSchema.index({ type: 1, appName: 1, createdAt: -1 })
logSchema.index({ type: 1, level: 1, createdAt: -1 }) logSchema.index({ type: 1, level: 1, createdAt: -1 })
// 统计接口只按时间范围过滤(getStats),以及 recentLogs 按时间排序取前 10
logSchema.index({ createdAt: -1 })
// 查询接口固定带 type='api' + 时间排序,上面的复合索引因中间列 appName/level 用不上排序
logSchema.index({ type: 1, createdAt: -1 })
export default mongoose.model('Log', logSchema) export default mongoose.model('Log', logSchema)
import { success } from '../../../lib/response.js'
import { getApplicationUsers } from './permission.client.js'
import { getTgc } from './auth.service.js'
export const me = (req, res) => success(res, {
user: {
ssoId: req.user.ssoId,
id: req.user.ssoId,
name: req.user.name,
mobile: req.user.mobile,
avatar: req.user.avatar,
},
roles: req.user.roles,
routes: req.user.routes,
})
export const users = async (req, res) => {
const result = await getApplicationUsers(getTgc(req), {
page: req.query.page || 1,
limit: req.query.limit || 100,
...(req.query.ssoId ? { sso_id: req.query.ssoId } : {}),
})
success(res, result)
}
import { authenticateRequest } from './auth.service.js'
import { hasAnyDmsRouteAccess, hasRouteAccess } from './route-access.js'
export const authenticate = async (req, _res, next) => {
try {
req.user = await authenticateRequest(req)
next()
} catch (error) {
next(error)
}
}
export const requireAnyDmsRouteAccess = (req, _res, next) => {
if (hasAnyDmsRouteAccess(req.user)) return next()
const error = new Error('无权访问 DMS 模块')
error.statusCode = 403
next(error)
}
export const requireRouteAccess = (routePrefix) => (req, _res, next) => {
if (hasRouteAccess(req.user, routePrefix)) return next()
const error = new Error('无权访问当前 DMS 模块')
error.statusCode = 403
next(error)
}
export const hasRole = (user, role) => user?.roles?.includes('admin') || user?.roles?.includes(role)
export const requireRole = (...roles) => (req, _res, next) => {
if (req.user?.roles?.includes('admin') || roles.some((role) => hasRole(req.user, role))) return next()
const error = new Error('当前角色无权执行该操作')
error.statusCode = 403
next(error)
}
import * as casesService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const list = async (req, res) => {
const { keyword, productId, page, limit } = req.query
const result = await casesService.listCases({ keyword, productId }, { page, limit })
success(res, result)
}
export const sync = async (_req, res) => {
const result = await casesService.syncCasesToPublic()
success(res, result)
}
export const detail = async (req, res) => {
const record = await casesService.getCaseById(req.params.id)
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
export const create = async (req, res) => {
const record = await casesService.createCase(req.body, getCurrentUser(req))
success(res, record, 201)
}
export const update = async (req, res) => {
const record = await casesService.updateCase(req.params.id, req.body, getCurrentUser(req))
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
export const remove = async (req, res) => {
const record = await casesService.deleteCase(req.params.id)
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
import { Router } from 'express'
import { validate } from '../../lib/validate.js'
import * as projectsController from './projects/controller.js'
import * as productsController from './products/controller.js'
import * as casesController from './cases/controller.js'
import * as authController from './auth/auth.controller.js'
import {
authenticate,
requireAnyDmsRouteAccess,
requireRouteAccess,
requireRole,
} from './auth/auth.middleware.js'
import * as projectRolesController from './project-roles/controller.js'
import { requireProjectAccess, requireProjectRole } from './project-roles/access.middleware.js'
import {
acceptanceUpsertBody,
caseCreateBody,
casesListQuery,
caseUpdateBody,
contractUpsertBody,
deliveryUpsertBody,
idParam,
initiationUpsertBody,
moveStageBody,
procurementUpsertBody,
productCreateBody,
productsListQuery,
productUpdateBody,
productUpdateStatusBody,
projectCodeParam,
projectCreateBody,
projectsListQuery,
projectUpdateBody,
projectTeamBody,
rollbackStageBody,
solutionUpsertBody,
} from './dms.validations.js'
const router = Router()
router.get('/dms/auth/me', authenticate, authController.me)
router.use('/dms', authenticate)
router.get('/dms/system/users', requireRouteAccess('/dms/projects'), authController.users)
// ── products ──
router.get(
'/dms/products',
requireAnyDmsRouteAccess,
validate({ query: productsListQuery }),
productsController.list,
)
const productsManageRouter = Router()
productsManageRouter.post('/', validate({ body: productCreateBody }), productsController.create)
productsManageRouter.put('/:id', validate({ params: idParam, body: productUpdateBody }), productsController.update)
productsManageRouter.put('/:id/status', validate({ params: idParam, body: productUpdateStatusBody }), productsController.updateStatus)
productsManageRouter.delete('/:id', validate({ params: idParam }), productsController.remove)
router.use('/dms/products', requireRouteAccess('/dms/products'), productsManageRouter)
// ── cases ──
const casesRouter = Router()
casesRouter.get('/', validate({ query: casesListQuery }), casesController.list)
casesRouter.post('/sync', casesController.sync)
casesRouter.post('/', validate({ body: caseCreateBody }), casesController.create)
casesRouter.get('/:id', validate({ params: idParam }), casesController.detail)
casesRouter.put('/:id', validate({ params: idParam, body: caseUpdateBody }), casesController.update)
casesRouter.delete('/:id', validate({ params: idParam }), casesController.remove)
router.use('/dms/cases', requireRouteAccess('/dms/cases'), casesRouter)
// ── projects ──
const projectsRouter = Router()
projectsRouter.post('/', validate({ body: projectCreateBody }), projectsController.create)
projectsRouter.get('/', validate({ query: projectsListQuery }), projectsController.list)
projectsRouter.get('/:projectCode', validate({ params: projectCodeParam }), requireProjectAccess, projectsController.detail)
projectsRouter.put('/:projectCode', validate({ params: projectCodeParam, body: projectUpdateBody }), requireProjectRole('project_manager'), projectsController.update)
projectsRouter.delete('/:projectCode', requireRole('admin'), validate({ params: projectCodeParam }), projectsController.remove)
projectsRouter.put('/:projectCode/stage', validate({ params: projectCodeParam, body: moveStageBody }), requireProjectAccess, projectsController.moveStage)
projectsRouter.put('/:projectCode/stage/rollback', validate({ params: projectCodeParam, body: rollbackStageBody }), requireProjectRole('project_manager'), projectsController.rollbackStage)
projectsRouter.put('/:projectCode/solution', validate({ params: projectCodeParam, body: solutionUpsertBody }), requireProjectRole('solution_owner', 'project_manager'), projectsController.updateSolution)
projectsRouter.get('/:projectCode/team', validate({ params: projectCodeParam }), requireProjectAccess, projectRolesController.list)
projectsRouter.put('/:projectCode/team', validate({ params: projectCodeParam, body: projectTeamBody }), requireProjectRole('project_manager'), projectRolesController.replace)
projectsRouter.put(
'/:projectCode/initiation',
validate({ params: projectCodeParam, body: initiationUpsertBody }),
requireProjectRole('initiation_owner', 'project_manager'),
projectsController.upsertInitiation,
)
projectsRouter.put(
'/:projectCode/procurement',
validate({ params: projectCodeParam, body: procurementUpsertBody }),
requireProjectRole('procurement_owner', 'project_manager'),
projectsController.upsertProcurement,
)
projectsRouter.put(
'/:projectCode/contract',
validate({ params: projectCodeParam, body: contractUpsertBody }),
requireProjectRole('contract_owner', 'project_manager'),
projectsController.upsertContract,
)
projectsRouter.put(
'/:projectCode/delivery',
validate({ params: projectCodeParam, body: deliveryUpsertBody }),
requireProjectRole('delivery_owner', 'project_manager'),
projectsController.upsertDelivery,
)
projectsRouter.put(
'/:projectCode/acceptance',
validate({ params: projectCodeParam, body: acceptanceUpsertBody }),
requireProjectRole('acceptance_owner', 'project_manager'),
projectsController.upsertAcceptance,
)
router.use('/dms/projects', requireRouteAccess('/dms/projects'), projectsRouter)
export default router
import { z } from 'zod'
// ── shared ───────────────────────────────────────────────────────────
const pagination = {
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
}
const optionalNumber = z.preprocess(
(value) => value === null || value === '' ? undefined : value,
z.coerce.number().optional(),
)
const optionalId = z.preprocess(
(value) => value === '' ? undefined : value,
z.coerce.number().int().positive().nullish(),
)
const fileListValue = z.string().nullish()
// ── products ─────────────────────────────────────────────────────────
export const productsListQuery = z.object({
keyword: z.string().optional(),
status: z.coerce.number().int().optional(),
...pagination,
})
const productBody = z.object({
name: z.string().trim().min(1).max(120),
status: z.coerce.number().int().optional(),
description: z.string().trim().optional(),
})
export const productCreateBody = productBody
export const productUpdateBody = productBody
export const productUpdateStatusBody = z.object({
status: z.number().int(),
})
export const idParam = z.object({
id: z.coerce.number().int().positive(),
})
// ── cases ────────────────────────────────────────────────────────────
export const casesListQuery = z.object({
keyword: z.string().optional(),
productId: z.coerce.number().int().optional(),
...pagination,
})
const caseBody = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().optional(),
productId: optionalId,
productName: z.string().max(255).optional(),
files: z.array(z.unknown()).optional(),
})
export const caseCreateBody = caseBody
export const caseUpdateBody = caseBody
// ── projects ─────────────────────────────────────────────────────────
export const projectsListQuery = z.object({
keyword: z.string().optional(),
stage: z.coerce.number().int().optional(),
status: z.coerce.number().int().optional(),
...pagination,
})
export const projectCodeParam = z.object({
projectCode: z.string().min(1).max(64),
})
export const projectCreateBody = z.object({
name: z.string().trim().min(1).max(255),
province: z.string().trim().min(1).max(120),
city: z.string().trim().min(1).max(120),
schoolName: z.string().trim().min(1).max(255),
departmentName: z.string().max(255).optional(),
productId: z.coerce.number().int().positive(),
productName: z.string().max(255).optional(),
contactName: z.string().max(120).optional(),
contactTitle: z.string().max(120).optional(),
contactPhone: z.string().max(64).optional(),
description: z.string().optional(),
})
export const projectUpdateBody = projectCreateBody.partial()
export const solutionUpsertBody = z.object({
solution: z.string().nullish(),
})
export const moveStageBody = z.object({
toStage: z.coerce.number().int(),
description: z.string().optional(),
})
export const rollbackStageBody = z.object({
reason: z.string().min(1).max(500),
})
export const projectTeamBody = z.object({
assignments: z.array(z.object({
ssoId: z.string().min(1).max(64),
userName: z.string().max(120).optional(),
mobile: z.string().max(64).optional(),
role: z.enum([
'project_manager', 'solution_owner', 'initiation_owner', 'procurement_owner',
'contract_owner', 'delivery_owner', 'acceptance_owner', 'viewer',
]),
})),
})
// ── phase upserts ────────────────────────────────────────────────────
export const initiationUpsertBody = z.object({
applicationFileUrl: fileListValue,
argumentFileUrl: fileListValue,
projectAmount: optionalNumber,
fundSource: z.string().max(255).nullish(),
executionPlan: z.string().nullish(),
expectedTenderTime: z.string().max(120).nullish(),
})
export const procurementUpsertBody = z.object({
tenderOwner: z.string().max(120).nullish(),
businessParams: z.string().nullish(),
technicalParams: z.string().nullish(),
intentionPublicity: z.string().max(255).nullish(),
mainBidOwner: z.string().max(120).nullish(),
companionBidders: z.string().nullish(),
formalBidStatus: z.string().max(120).nullish(),
winningNoticeFileUrl: fileListValue,
bidArchiveFileUrl: fileListValue,
})
export const contractUpsertBody = z.object({
contractNo: z.string().max(128).nullish(),
contractName: z.string().max(255).nullish(),
amount: optionalNumber,
drafter: z.string().max(120).nullish(),
archiveFileUrl: fileListValue,
})
export const deliveryUpsertBody = z.object({
deliveryAddress: z.string().max(255).nullish(),
deliveryContact: z.string().max(120).nullish(),
deliveryContactPhone: z.string().max(64).nullish(),
deliveryNote: z.string().nullish(),
})
export const acceptanceUpsertBody = z.object({
acceptanceReportUrl: fileListValue,
acceptanceNote: z.string().nullish(),
})
import * as productsService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const list = async (req, res) => {
const { keyword, status, page, limit } = req.query
const result = await productsService.listProducts({ keyword, status }, { page, limit })
success(res, result)
}
export const create = async (req, res) => {
const product = await productsService.createProduct(req.body, getCurrentUser(req))
success(res, product, 201)
}
export const update = async (req, res) => {
const product = await productsService.updateProduct(req.params.id, req.body, getCurrentUser(req))
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
export const updateStatus = async (req, res) => {
const product = await productsService.updateProductStatus(req.params.id, req.body, getCurrentUser(req))
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
export const remove = async (req, res) => {
const product = await productsService.deleteProduct(req.params.id)
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
import { getProjectByCode, userHasAnyProjectRole } from './service.js'
export const requireProjectRole = (...roles) => async (req, _res, next) => {
try {
if (req.user?.roles?.includes('admin')) return next()
const project = await getProjectByCode(req.params.projectCode)
if (!project) {
const error = new Error('项目不存在')
error.statusCode = 404
throw error
}
const allowed = await userHasAnyProjectRole(project.id, req.user.ssoId, roles)
if (!allowed) {
const error = new Error('您不是当前项目的授权负责人')
error.statusCode = 403
throw error
}
req.project = project
next()
} catch (error) {
next(error)
}
}
export const requireProjectAccess = async (req, _res, next) => {
try {
if (req.user?.roles?.includes('admin')) return next()
const project = await getProjectByCode(req.params.projectCode)
if (!project) {
const error = new Error('项目不存在')
error.statusCode = 404
throw error
}
const allowed = await userHasAnyProjectRole(project.id, req.user?.ssoId)
if (!allowed) {
const error = new Error('您不是该项目成员')
error.statusCode = 403
throw error
}
req.project = project
next()
} catch (error) {
next(error)
}
}
import { error, success } from '../../../lib/response.js'
import * as projectRolesService from './service.js'
export const list = async (req, res) => {
const project = await projectRolesService.getProjectByCode(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
success(res, { assignments: await projectRolesService.listAssignments(project.id) })
}
export const replace = async (req, res) => {
const project = await projectRolesService.getProjectByCode(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
const assignments = await projectRolesService.replaceAssignments(project.id, req.body.assignments, req.user)
success(res, { assignments })
}
import * as projectsService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
import { listAssignments } from '../project-roles/service.js'
export const create = async (req, res) => {
const project = await projectsService.createProject(req.body, getCurrentUser(req))
success(res, project, 201)
}
export const list = async (req, res) => {
const { keyword, stage, status, page, limit } = req.query
const result = await projectsService.listProjects(
{ keyword, stage, status },
{ page, limit },
req.user ? { userId: req.user.ssoId, roles: req.user.roles || [] } : {},
)
success(res, result)
}
export const detail = async (req, res) => {
const project = await projectsService.getProjectDetail(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
const assignments = await listAssignments(project.id)
success(res, { ...project, team: assignments, access: await projectsService.getProjectAccess(project, req.user) })
}
export const update = async (req, res) => {
const project = await projectsService.updateProject(req.params.projectCode, req.body, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
const upsertPhase = (handler) => async (req, res) => {
const project = await handler(req.params.projectCode, req.body, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
export const upsertInitiation = upsertPhase(projectsService.upsertInitiation)
export const upsertProcurement = upsertPhase(projectsService.upsertProcurement)
export const upsertContract = upsertPhase(projectsService.upsertContract)
export const upsertDelivery = upsertPhase(projectsService.upsertDelivery)
export const upsertAcceptance = upsertPhase(projectsService.upsertAcceptance)
export const updateSolution = upsertPhase(projectsService.updateSolution)
export const moveStage = async (req, res) => {
const project = await projectsService.moveProjectStage(req.params.projectCode, req.body, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
export const rollbackStage = async (req, res) => {
const project = await projectsService.rollbackProjectStage(req.params.projectCode, req.body.reason, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
export const remove = async (req, res) => {
const project = await projectsService.deleteProject(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
import { and, count, desc, eq, inArray, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.js'
import {
acceptances,
contracts,
deliveries,
initiations,
procurements,
projects,
PROJECT_STATUSES,
PROJECT_STAGES,
projectTimelines,
} from './schema.js'
import { products } from '../products/schema.js'
import { isManualTransitionAllowed, isProjectDeleteAllowed } from './workflow.js'
import { projectRoleAssignments } from '../project-roles/schema.js'
const STAGE_LABELS = {
[PROJECT_STAGES.SOLUTION]: '方案阶段',
[PROJECT_STAGES.INITIATION]: '立项阶段',
[PROJECT_STAGES.PROCUREMENT]: '招投标阶段',
[PROJECT_STAGES.CONTRACT]: '合同阶段',
[PROJECT_STAGES.DELIVERY]: '交付阶段',
[PROJECT_STAGES.ACCEPTANCE]: '验收阶段',
[PROJECT_STAGES.ARCHIVED]: '归档',
}
const STATUS_LABELS = {
[PROJECT_STATUSES.ACTIVE]: '进行中',
[PROJECT_STATUSES.ARCHIVED]: '已归档',
}
const STAGE_OWNER_ROLES = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const PROJECT_OWNER_ROLES = ['project_manager', ...Object.values(STAGE_OWNER_ROLES)]
const withProjectOwnerFields = (project, assignments = []) => {
const projectManagers = assignments.filter((item) => item.role === 'project_manager')
const stageOwners = assignments.filter((item) => item.role === STAGE_OWNER_ROLES[project.stage])
return {
...project,
projectManagerSsoIds: projectManagers.map((item) => item.ssoId),
projectManagerName: projectManagers.map((item) => item.userName || item.ssoId).join('、') || null,
currentStageOwnerName: stageOwners.map((item) => item.userName || item.ssoId).join('、') || null,
currentStageOwnerMobile: stageOwners.map((item) => item.mobile).filter(Boolean).join('、') || null,
currentStageOwnerSsoIds: stageOwners.map((item) => item.ssoId),
}
}
const buildProjectCode = () => {
const date = new Date()
const ymd = date.toISOString().slice(0, 10).replaceAll('-', '')
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase()
return `DMS${ymd}${suffix}`
}
const toNumberOrUndefined = (value) => {
if (value === undefined || value === null || value === '') return undefined
const parsed = Number(value)
return Number.isNaN(parsed) ? undefined : parsed
}
const pickDefined = (payload, fields) =>
fields.reduce((values, field) => {
if (payload[field] !== undefined) values[field] = payload[field]
return values
}, {})
const FILE_URL_FIELDS = new Set([
'applicationFileUrl',
'argumentFileUrl',
'winningNoticeFileUrl',
'bidArchiveFileUrl',
'archiveFileUrl',
'acceptanceReportUrl',
])
const normalizeFileUrls = (value) => {
if (value === null || value === '') return ''
let files
try {
files = JSON.parse(value)
} catch {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
if (!Array.isArray(files) || files.some((file) => (
!file
|| typeof file.url !== 'string' || !file.url
|| typeof file.size !== 'string'
|| typeof file.name !== 'string' || !file.name
|| typeof file.type !== 'string'
))) {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
return JSON.stringify(files.map(({ url, size, name, type }) => ({ url, size, name, type })))
}
const hasFiles = (value) => {
if (!value) return false
try {
const files = JSON.parse(value)
return Array.isArray(files) && files.length > 0
} catch {
return false
}
}
const normalizeFileUrlFields = (values) => {
for (const field of Object.keys(values)) {
if (FILE_URL_FIELDS.has(field)) values[field] = normalizeFileUrls(values[field])
}
return values
}
const assertProjectStageEditable = (project, stage) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改阶段资料')
if (project.stage !== stage) throw httpError(409, '只能修改当前阶段资料,请先退回到该阶段')
}
const assertProjectBaseEditable = (project) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改基础信息')
if (![PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage)) {
throw httpError(409, '进入后续阶段后不可修改项目基础信息')
}
}
const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null,
})
const getProjectByCodeWithDb = async (db, projectCode) => {
const rows = await db
.select({ project: projects, productName: products.name })
.from(projects)
.leftJoin(products, eq(projects.productId, products.id))
.where(eq(projects.projectCode, projectCode))
.limit(1)
if (!rows[0]) return null
return {
...rows[0].project,
productName: rows[0].productName || '',
}
}
const resolveProductFields = async (db, payload = {}) => {
const productId = toNumberOrUndefined(payload.productId)
if (productId === undefined) {
return payload.productName !== undefined ? { productName: payload.productName || null } : {}
}
const rows = await db.select().from(products).where(eq(products.id, productId)).limit(1)
const product = rows[0]
if (!product) {
throw httpError(400, '产品不存在')
}
return {
productId,
productName: product.name,
}
}
export const appendTimeline = async (db, project, { title, description, stage, status, operatorUserId, operatorName }) => {
await db.insert(projectTimelines).values({
projectId: project.id,
projectCode: project.projectCode,
title,
description: description || null,
stage: stage ?? project.stage,
status: status ?? project.status,
operatorUserId: operatorUserId || null,
operatorName: operatorName || null,
})
}
const appendStageTimeline = async (db, project, stage, status, description, options = {}) => {
await appendTimeline(db, project, {
title: `${STAGE_LABELS[stage] || '项目阶段'}${STATUS_LABELS[status] ? ` - ${STATUS_LABELS[status]}` : ''}`,
description,
stage,
status,
operatorUserId: options.operatorUserId,
operatorName: options.operatorName,
})
}
export const createProject = async (payload = {}, currentUser = {}) => {
const projectCode = payload.projectCode || buildProjectCode()
await db.transaction(async (tx) => {
const productFields = await resolveProductFields(tx, payload)
const [insertResult] = await tx.insert(projects).values({
projectCode,
name: payload.name,
province: payload.province || null,
city: payload.city || null,
schoolName: payload.schoolName || null,
departmentName: payload.departmentName || null,
productId: productFields.productId || null,
productName: productFields.productName || null,
contactName: payload.contactName || null,
contactTitle: payload.contactTitle || null,
contactPhone: payload.contactPhone || null,
solution: payload.solution || null,
stage: toNumberOrUndefined(payload.stage) ?? PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
description: payload.description || null,
...resolveOperator(currentUser),
})
const project = await getProjectByCodeWithDb(tx, projectCode)
const projectId = insertResult.insertId || project.id
if (currentUser.userId) {
await tx.insert(projectRoleAssignments).values({
projectId,
ssoId: currentUser.userId,
userName: currentUser.userName || null,
mobile: currentUser.mobile || null,
role: 'project_manager',
assignedBySsoId: currentUser.userId,
})
}
await tx.insert(initiations).values({ projectId })
await tx.insert(procurements).values({ projectId })
await tx.insert(contracts).values({ projectId })
await tx.insert(deliveries).values({ projectId })
await tx.insert(acceptances).values({ projectId })
await appendTimeline(tx, { ...project, id: projectId }, {
title: '创建项目',
description: '项目已创建,进入方案阶段',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
operatorName: currentUser.userName || null,
operatorUserId: currentUser.userId || null,
})
})
return getProjectDetail(projectCode)
}
const buildProjectFilters = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
const matchedOwnerProjects = db
.select({ projectId: projectRoleAssignments.projectId })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
or(
like(projectRoleAssignments.userName, keyword),
like(projectRoleAssignments.mobile, keyword),
),
))
conditions.push(or(
like(projects.projectCode, keyword),
like(projects.name, keyword),
like(projects.schoolName, keyword),
like(projects.operatorName, keyword),
inArray(projects.id, matchedOwnerProjects),
))
}
const stage = toNumberOrUndefined(filters.stage)
const status = toNumberOrUndefined(filters.status)
if (stage !== undefined) conditions.push(eq(projects.stage, stage))
if (status !== undefined) conditions.push(eq(projects.status, status))
if (filters.projectIds) conditions.push(inArray(projects.id, filters.projectIds))
return conditions.length ? and(...conditions) : undefined
}
export const listProjects = async (filters = {}, options = {}, currentUser = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
let projectIds
if (!currentUser.roles?.includes('admin')) {
const rows = await db.select({ projectId: projectRoleAssignments.projectId })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.ssoId, currentUser.userId), eq(projectRoleAssignments.status, 1)))
projectIds = rows.map((row) => row.projectId)
if (!projectIds.length) return { list: [], pagination: { page, limit, total: 0, pages: 0 } }
}
const where = buildProjectFilters({ ...filters, projectIds })
const [projectRows, totalRows] = await Promise.all([
db
.select({ project: projects, productName: products.name })
.from(projects)
.leftJoin(products, eq(projects.productId, products.id))
.where(where)
.orderBy(desc(projects.createdAt))
.limit(limit)
.offset(offset),
db.select({ total: count() }).from(projects).where(where),
])
const projectIdsInPage = projectRows.map((row) => row.project.id)
const assignmentRows = projectIdsInPage.length
? await db.select({
projectId: projectRoleAssignments.projectId,
ssoId: projectRoleAssignments.ssoId,
userName: projectRoleAssignments.userName,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
inArray(projectRoleAssignments.projectId, projectIdsInPage),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
: []
const assignmentsByProjectId = new Map()
for (const assignment of assignmentRows) {
const current = assignmentsByProjectId.get(assignment.projectId) || []
current.push(assignment)
assignmentsByProjectId.set(assignment.projectId, current)
}
const list = projectRows.map((row) => ({
...withProjectOwnerFields(row.project, assignmentsByProjectId.get(row.project.id)),
canEditProject: currentUser.roles?.includes('admin')
|| (
row.project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(row.project.stage)
&& assignmentsByProjectId.get(row.project.id)?.some((item) => item.role === 'project_manager' && item.ssoId === currentUser.userId)
),
productName: row.productName || '',
}))
const total = totalRows[0]?.total || 0
return {
list,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
export const getProjectByCode = async (projectCode) => getProjectByCodeWithDb(db, projectCode)
export const getProjectDetail = async (projectCode) => {
const project = await getProjectByCode(projectCode)
if (!project) return null
const [
initiationRows,
procurementRows,
contractRows,
deliveryRows,
acceptanceRows,
timelineRows,
] = await Promise.all([
db.select().from(initiations).where(eq(initiations.projectId, project.id)).limit(1),
db.select().from(procurements).where(eq(procurements.projectId, project.id)).limit(1),
db.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1),
db.select().from(deliveries).where(eq(deliveries.projectId, project.id)).limit(1),
db.select().from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1),
db.select().from(projectTimelines).where(eq(projectTimelines.projectId, project.id)).orderBy(desc(projectTimelines.createdAt)),
])
const ownerRows = await db.select({
ssoId: projectRoleAssignments.ssoId,
userName: projectRoleAssignments.userName,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
eq(projectRoleAssignments.projectId, project.id),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
return {
...withProjectOwnerFields(project, ownerRows),
initiation: initiationRows[0] || {},
procurement: procurementRows[0] || {},
contract: contractRows[0] || {},
delivery: deliveryRows[0] || {},
acceptance: acceptanceRows[0] || {},
timeline: timelineRows,
}
}
export const getProjectAccess = async (project, user = {}) => {
const activeStages = Object.values(PROJECT_STAGES).filter((stage) => stage !== PROJECT_STAGES.ARCHIVED)
const editableStages = project.status === PROJECT_STATUSES.ACTIVE && activeStages.includes(project.stage)
? [project.stage]
: []
if (user?.roles?.includes('admin')) {
return {
canEditProject: project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage),
canManageTeam: true,
canMoveStage: project.status === PROJECT_STATUSES.ACTIVE,
canRollbackStage: true,
editableStages,
}
}
const roles = await getUserProjectRoles(project.id, user?.ssoId)
const isProjectManager = roles.includes('project_manager')
const isCurrentOwner = roles.includes(STAGE_OWNER_ROLES[project.stage])
return {
canEditProject: isProjectManager && project.status === PROJECT_STATUSES.ACTIVE,
canManageTeam: isProjectManager,
canMoveStage: project.status === PROJECT_STATUSES.ACTIVE && (isProjectManager || isCurrentOwner),
canRollbackStage: isProjectManager,
editableStages: isProjectManager || isCurrentOwner ? editableStages : [],
}
}
export const updateProject = async (projectCode, payload = {}, currentUser = {}) => {
const values = pickDefined(payload, [
'name',
'province',
'city',
'schoolName',
'departmentName',
'contactName',
'contactTitle',
'contactPhone',
'description',
])
Object.assign(values, await resolveProductFields(db, payload))
if (Object.keys(values).length) {
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
assertProjectBaseEditable(project)
await tx.update(projects).set({ ...values, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
}
return getProjectDetail(projectCode)
}
export const updateSolution = async (projectCode, payload = {}, currentUser = {}) => {
const solution = typeof payload.solution === 'string' ? payload.solution : ''
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
assertProjectStageEditable(project, PROJECT_STAGES.SOLUTION)
await tx.update(projects).set({ solution, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
return exists ? getProjectDetail(projectCode) : null
}
const getOrCreatePhaseRow = async (tx, table, projectId) => {
const rows = await tx.select().from(table).where(eq(table.projectId, projectId)).limit(1)
if (rows[0]) return rows[0]
const [result] = await tx.insert(table).values({ projectId })
return { id: result.insertId, projectId }
}
const upsertPhase = async (projectCode, table, payload, fields, stage, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
assertProjectStageEditable(project, stage)
const row = await getOrCreatePhaseRow(tx, table, project.id)
const values = normalizeFileUrlFields(pickDefined(payload, fields))
if (Object.keys(values).length) {
await tx.update(table).set(values).where(eq(table.id, row.id))
await tx.update(projects).set(resolveOperator(currentUser)).where(eq(projects.id, project.id))
}
})
return exists ? getProjectDetail(projectCode) : null
}
export const upsertInitiation = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, initiations, payload, [
'applicationFileUrl',
'argumentFileUrl',
'projectAmount',
'fundSource',
'executionPlan',
'expectedTenderTime',
], PROJECT_STAGES.INITIATION, currentUser)
export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, procurements, payload, [
'tenderOwner',
'businessParams',
'technicalParams',
'intentionPublicity',
'mainBidOwner',
'companionBidders',
'formalBidStatus',
'winningNoticeFileUrl',
'bidArchiveFileUrl',
], PROJECT_STAGES.PROCUREMENT, currentUser)
export const upsertContract = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, contracts, payload, ['contractNo', 'contractName', 'amount', 'drafter', 'archiveFileUrl'], PROJECT_STAGES.CONTRACT, currentUser)
export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, deliveries, payload, [
'deliveryAddress',
'deliveryContact',
'deliveryContactPhone',
'deliveryNote',
], PROJECT_STAGES.DELIVERY, currentUser)
export const upsertAcceptance = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, acceptances, payload, ['acceptanceReportUrl', 'acceptanceNote'], PROJECT_STAGES.ACCEPTANCE, currentUser)
export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => {
const stage = toNumberOrUndefined(payload.toStage)
if (stage === undefined) {
throw httpError(400, 'toStage 不能为空')
}
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
const status = stage === PROJECT_STAGES.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE
const matchedTransition = isManualTransitionAllowed({
fromStage: project.stage,
fromStatus: project.status,
toStage: stage,
toStatus: status,
})
if (!matchedTransition) throw httpError(400, '不允许的阶段流转')
const isAdministrator = currentUser.roles?.includes('admin')
if (!isAdministrator) {
const roleByStage = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager') && !roles.includes(roleByStage[project.stage])) {
throw httpError(403, '您不是当前阶段负责人')
}
}
if (project.stage === PROJECT_STAGES.DELIVERY) {
await tx.update(deliveries)
.set({ completedAt: new Date() })
.where(eq(deliveries.projectId, project.id))
}
await validateStageCompletion(tx, project)
await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, stage, status, payload.description || '已完成当前阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
const getUserProjectRoles = async (projectId, ssoId, database = db) => {
if (!ssoId) return []
const rows = await database.select({ role: projectRoleAssignments.role })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.projectId, projectId), eq(projectRoleAssignments.ssoId, ssoId), eq(projectRoleAssignments.status, 1)))
return rows.map((row) => row.role)
}
const validateStageCompletion = async (tx, project) => {
if (project.stage === PROJECT_STAGES.SOLUTION && !project.solution?.trim()) {
throw httpError(400, '方案完成前请填写方案内容')
}
if (project.stage === PROJECT_STAGES.INITIATION) {
const rows = await tx.select({ applicationFileUrl: initiations.applicationFileUrl }).from(initiations).where(eq(initiations.projectId, project.id)).limit(1)
if (!hasFiles(rows[0]?.applicationFileUrl)) throw httpError(400, '立项完成前请上传项目申报书')
}
if (project.stage === PROJECT_STAGES.CONTRACT) {
const rows = await tx.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1)
const contract = rows[0]
if (!contract?.contractNo?.trim() || !contract.contractName?.trim() || !hasFiles(contract.archiveFileUrl)) {
throw httpError(400, '合同完成前请填写合同编号、合同名称并上传合同文件')
}
}
if (project.stage === PROJECT_STAGES.ACCEPTANCE) {
const rows = await tx.select({ acceptanceReportUrl: acceptances.acceptanceReportUrl }).from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1)
if (!hasFiles(rows[0]?.acceptanceReportUrl)) throw httpError(400, '验收完成前请上传验收报告')
}
}
export const rollbackProjectStage = async (projectCode, reason, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
if (!currentUser.roles?.includes('admin')) {
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager')) throw httpError(403, '只有项目负责人和管理员可以退回阶段')
}
const previousStage = {
[PROJECT_STAGES.INITIATION]: PROJECT_STAGES.SOLUTION,
[PROJECT_STAGES.PROCUREMENT]: PROJECT_STAGES.INITIATION,
[PROJECT_STAGES.CONTRACT]: PROJECT_STAGES.PROCUREMENT,
[PROJECT_STAGES.DELIVERY]: PROJECT_STAGES.CONTRACT,
[PROJECT_STAGES.ACCEPTANCE]: PROJECT_STAGES.DELIVERY,
[PROJECT_STAGES.ARCHIVED]: PROJECT_STAGES.ACCEPTANCE,
}[project.stage]
if (!previousStage) throw httpError(400, '当前阶段不能退回')
await tx.update(projects).set({ stage: previousStage, status: PROJECT_STATUSES.ACTIVE, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, previousStage, PROJECT_STATUSES.ACTIVE, reason || '项目退回上一阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
export const deleteProject = async (projectCode) => {
let removedProject = null
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
if (!isProjectDeleteAllowed({ stage: project.stage, status: project.status })) {
throw httpError(400, '仅方案/立项阶段的进行中项目允许删除')
}
await tx.delete(projects).where(eq(projects.id, project.id))
removedProject = project
})
return removedProject
}
export const getCurrentUser = (req) => ({
userId: req.user?.ssoId || null,
userName: req.user?.name || null,
mobile: req.user?.mobile || null,
roles: req.user?.roles || [],
})
import { Router } from 'express'
import { validate } from '../../lib/validate.js'
import * as controller from './logs.controller.js'
import {
createBatchBody,
createLogBody,
queryLogsQuery,
statsQuery,
} from './logs.validations.js'
const router = Router()
router.post('/logs', validate({ body: createLogBody }), controller.create)
router.post('/logs/batch', validate({ body: createBatchBody }), controller.createBatch)
router.get('/logs', validate({ query: queryLogsQuery }), controller.query)
router.get('/logs/stats', validate({ query: statsQuery }), controller.getStats)
export default router
import * as wechatService from './wechat.service.js'
export const getSignature = async (req, res) => {
const { appId, url } = req.body
const ticket = await wechatService.getCachedTicket(appId)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (url || req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
res.json({
url: rawUrl,
ticket: ticket.jsapi_ticket,
token: signature,
noncestr: ticket.noncestr,
timestamp: ticket.timestamp,
})
}
export const share = async (req, res) => {
const { appId } = req.body
const ticket = await wechatService.getCachedTicket(appId)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
res.json({ token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp })
}
export const getInfo = async (req, res) => {
const { appId, code } = req.body
const userInfo = await wechatService.getUserInfo(appId, code)
res.json(userInfo)
}
import { Router } from 'express'
import { validate } from '../../lib/validate.js'
import * as controller from './wechat.controller.js'
import { getInfoBody, getSignatureBody, shareBody } from './wechat.validations.js'
const router = Router()
router.post('/share/getsignature', validate({ body: getSignatureBody }), controller.getSignature)
router.post('/share/token', validate({ body: shareBody }), controller.share)
router.post('/getInfo', validate({ body: getInfoBody }), controller.getInfo)
export default router
import * as service from './wx-chart.service.js'
export const get = async (req, res) => {
const key = req.params.key
if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
const value = await service.getValue(key)
res.json({ success: true, data: { code: value } })
}
export const set = async (req, res) => {
const key = req.params.key
if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
const data = await service.setValue(key, req.query.val || 0)
res.json({ success: true, data })
}
import { Router } from 'express'
import * as controller from './wx-chart.controller.js'
const router = Router()
router.get('/get/wx-chart/{*key}', controller.get)
router.get('/set/wx-chart/{*key}', controller.set)
export default router
import fp from 'fastify-plugin'
import compress from '@fastify/compress'
// gzip/br 压缩(超过 1KB 的响应),对 JSON 接口收益明显
export default fp(async (fastify) => {
await fastify.register(compress, { global: true, threshold: 1024 })
}, { name: 'compress' })
import fp from 'fastify-plugin'
import cors from '@fastify/cors'
/**
* 允许任意来源调用:origin: true 会反射请求的 Origin。
* 注意不能用默认的 '*'——规范禁止 '*' 与 credentials 同时出现,
* 浏览器会直接拒绝带凭据(Cookie)的跨域请求。
*/
export default fp(async (fastify) => {
await fastify.register(cors, {
origin: true,
credentials: true,
exposedHeaders: ['x-request-id'],
})
}, { name: 'cors' })
import fp from 'fastify-plugin'
import formbody from '@fastify/formbody'
// application/x-www-form-urlencoded 请求体解析
export default fp(async (fastify) => {
await fastify.register(formbody)
}, { name: 'formbody' })
import fp from 'fastify-plugin'
import mongoose, { connectMongo, disconnectMongo } from '#src/lib/mongo.js'
/**
* MongoDB(mongoose)生命周期由该插件管理:启动时连接,关闭时断开。
* fastify.mongoose mongoose 实例
*/
export default fp(async (fastify) => {
await connectMongo()
fastify.decorate('mongoose', mongoose)
fastify.addHook('onClose', async () => {
await disconnectMongo()
})
}, { name: 'mongo' })
import fp from 'fastify-plugin'
import { getMysqlPool, closeMysqlPool } from '#src/lib/mysql.js'
import { db } from '#src/db/client.js'
/**
* MySQL / Drizzle 生命周期由该插件管理:
* fastify.mysql mysql2 连接池
* fastify.db Drizzle 实例
* 现有 service 仍可直接 import { db } from 'src/db/client.js'(同一个实例)。
*/
export default fp(async (fastify) => {
fastify.decorate('mysql', getMysqlPool())
fastify.decorate('db', db)
fastify.addHook('onClose', async () => {
await closeMysqlPool()
})
}, { name: 'mysql' })
import fp from 'fastify-plugin'
// 把 Fastify 生成的 reqId 回传,便于前端报错与服务端日志对齐
export default fp(async (fastify) => {
fastify.addHook('onSend', async (request, reply, payload) => {
reply.header('x-request-id', request.id)
return payload
})
}, { name: 'request-id' })
import fp from 'fastify-plugin'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import { jsonSchemaTransform } from 'fastify-type-provider-zod'
import config from '#src/config.js'
// OpenAPI 文档:/docs(UI)、/docs/json
export default fp(async (fastify) => {
if (!config.docs.enabled) return
await fastify.register(swagger, {
openapi: {
info: { title: 'ezijing-node-server', version: config.version },
},
transform: jsonSchemaTransform,
})
await fastify.register(swaggerUi, { routePrefix: config.docs.routePrefix })
}, { name: 'swagger', dependencies: ['zod'] })
import fp from 'fastify-plugin'
import httpProxy from '@fastify/http-proxy'
// /api/usercenter/* 透传到用户中心
export default fp(async (fastify) => {
await fastify.register(httpProxy, {
upstream: 'https://api-usercenter.ezijing.com',
prefix: '/api/usercenter',
})
}, { name: 'usercenter-proxy' })
import fp from 'fastify-plugin'
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
/**
* 让路由的 schema.{body,querystring,params,response} 直接使用 zod schema。
* 校验失败的错误由 src/app.js 的 error handler 统一格式化。
*/
export default fp(async (fastify) => {
fastify.setValidatorCompiler(validatorCompiler)
fastify.setSerializerCompiler(serializerCompiler)
}, { name: 'zod' })
import { success } from '#src/lib/response.js'
import { getTgc } from '#src/services/dms/auth.service.js'
import { getApplicationUsers } from '#src/clients/permission.client.js'
import { requireRouteAccess } from '#src/services/dms/hooks.js'
const me = (request, reply) => success(reply, {
user: {
ssoId: request.user.ssoId,
id: request.user.ssoId,
name: request.user.name,
mobile: request.user.mobile,
avatar: request.user.avatar,
},
roles: request.user.roles,
routes: request.user.routes,
})
const users = async (request, reply) => {
const result = await getApplicationUsers(getTgc(request), {
page: request.query.page || 1,
limit: request.query.limit || 100,
...(request.query.sso_id ? { sso_id: request.query.sso_id } : {}),
})
return success(reply, result)
}
export default async function dmsAuthRoutes(fastify) {
fastify.get('/auth/me', me)
fastify.get('/system/users', { preHandler: [requireRouteAccess('/dms/projects')] }, users)
}
import { authenticate } from '#src/services/dms/hooks.js'
// 本目录及子目录所有路由统一鉴权(onRequest 阶段,先于 schema 校验)
export default async function dmsAutohooks(fastify) {
fastify.addHook('onRequest', authenticate)
}
import { success, error } from '#src/lib/response.js'
import * as casesService from '#src/services/dms/cases.service.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import { requireRouteAccess } from '#src/services/dms/hooks.js'
import { idParam } from '#src/schemas/dms/common.js'
import { caseCreateBody, casesListQuery, caseUpdateBody } from '#src/schemas/dms/cases.js'
const list = async (request, reply) => {
const { keyword, product_id, page, limit } = request.query
const result = await casesService.listCases({ keyword, product_id }, { page, limit })
return success(reply, result)
}
const sync = async (_request, reply) => {
const result = await casesService.syncCasesToPublic()
return success(reply, result)
}
const detail = async (request, reply) => {
const record = await casesService.getCaseById(request.params.id)
if (!record) return error(reply, '案例不存在', 404)
return success(reply, record)
}
const create = async (request, reply) => {
const record = await casesService.createCase(request.body, getCurrentUser(request))
return success(reply, record, 201)
}
const update = async (request, reply) => {
const record = await casesService.updateCase(request.params.id, request.body, getCurrentUser(request))
if (!record) return error(reply, '案例不存在', 404)
return success(reply, record)
}
const remove = async (request, reply) => {
const record = await casesService.deleteCase(request.params.id)
if (!record) return error(reply, '案例不存在', 404)
return success(reply, record)
}
export default async function dmsCasesRoutes(fastify) {
// 本文件所有路由都属于 /dms/cases 权限范围,统一校验
fastify.addHook('preHandler', requireRouteAccess('/dms/cases'))
fastify.get(
'/cases',
{ schema: { querystring: casesListQuery } },
list,
)
fastify.post('/cases/sync', { preHandler: [requireRouteAccess('/dms/cases')] }, sync)
fastify.post(
'/cases',
{ schema: { body: caseCreateBody } },
create,
)
fastify.get(
'/cases/:id',
{ schema: { params: idParam } },
detail,
)
fastify.put(
'/cases/:id',
{ schema: { params: idParam, body: caseUpdateBody } },
update,
)
fastify.delete(
'/cases/:id',
{ schema: { params: idParam } },
remove,
)
}
import { success, error } from '#src/lib/response.js'
import * as productsService from '#src/services/dms/products.service.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import { requireAnyDmsRouteAccess, requireRouteAccess } from '#src/services/dms/hooks.js'
import { idParam } from '#src/schemas/dms/common.js'
import {
productCreateBody,
productsListQuery,
productUpdateBody,
productUpdateStatusBody,
} from '#src/schemas/dms/products.js'
const list = async (request, reply) => {
const { keyword, status, page, limit } = request.query
const result = await productsService.listProducts({ keyword, status }, { page, limit })
return success(reply, result)
}
const create = async (request, reply) => {
const product = await productsService.createProduct(request.body, getCurrentUser(request))
return success(reply, product, 201)
}
const update = async (request, reply) => {
const product = await productsService.updateProduct(request.params.id, request.body, getCurrentUser(request))
if (!product) return error(reply, '产品不存在', 404)
return success(reply, product)
}
const updateStatus = async (request, reply) => {
const product = await productsService.updateProductStatus(request.params.id, request.body, getCurrentUser(request))
if (!product) return error(reply, '产品不存在', 404)
return success(reply, product)
}
const remove = async (request, reply) => {
const product = await productsService.deleteProduct(request.params.id)
if (!product) return error(reply, '产品不存在', 404)
return success(reply, product)
}
export default async function dmsProductsRoutes(fastify) {
fastify.get(
'/products',
{ schema: { querystring: productsListQuery }, preHandler: [requireAnyDmsRouteAccess] },
list,
)
fastify.post(
'/products',
{ schema: { body: productCreateBody }, preHandler: [requireRouteAccess('/dms/products')] },
create,
)
fastify.put(
'/products/:id',
{ schema: { params: idParam, body: productUpdateBody }, preHandler: [requireRouteAccess('/dms/products')] },
update,
)
fastify.put(
'/products/:id/status',
{ schema: { params: idParam, body: productUpdateStatusBody }, preHandler: [requireRouteAccess('/dms/products')] },
updateStatus,
)
fastify.delete(
'/products/:id',
{ schema: { params: idParam }, preHandler: [requireRouteAccess('/dms/products')] },
remove,
)
}
import { success, error } from '#src/lib/response.js'
import * as projectsService from '#src/services/dms/projects/index.js'
import * as projectRolesService from '#src/services/dms/project-roles.service.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import {
requireProjectAccess,
requireProjectRole,
requireRole,
requireRouteAccess,
} from '#src/services/dms/hooks.js'
import {
acceptanceUpsertBody,
contractUpsertBody,
deliveryUpsertBody,
initiationUpsertBody,
moveStageBody,
procurementUpsertBody,
projectCodeParam,
projectCreateBody,
projectsListQuery,
projectTeamBody,
projectUpdateBody,
rollbackStageBody,
solutionUpsertBody,
} from '#src/schemas/dms/projects.js'
const create = async (request, reply) => {
const project = await projectsService.createProject(request.body, getCurrentUser(request))
return success(reply, project, 201)
}
const list = async (request, reply) => {
const { keyword, stage, status, page, limit } = request.query
const result = await projectsService.listProjects(
{ keyword, stage, status },
{ page, limit },
request.user ? { userId: request.user.ssoId, roles: request.user.roles || [] } : {},
)
return success(reply, result)
}
const detail = async (request, reply) => {
const project = await projectsService.getProjectDetail(request.params.projectCode)
if (!project) return error(reply, '项目不存在', 404)
const assignments = await projectRolesService.listAssignments(project.id)
return success(reply, { ...project, team: assignments, access: await projectsService.getProjectAccess(project, request.user) })
}
const update = async (request, reply) => {
const project = await projectsService.updateProject(request.params.projectCode, request.body, getCurrentUser(request))
if (!project) return error(reply, '项目不存在', 404)
return success(reply, project)
}
const upsertPhase = (handler) => async (request, reply) => {
const project = await handler(request.params.projectCode, request.body, getCurrentUser(request))
if (!project) return error(reply, '项目不存在', 404)
return success(reply, project)
}
const upsertInitiation = upsertPhase(projectsService.upsertInitiation)
const upsertProcurement = upsertPhase(projectsService.upsertProcurement)
const upsertContract = upsertPhase(projectsService.upsertContract)
const upsertDelivery = upsertPhase(projectsService.upsertDelivery)
const upsertAcceptance = upsertPhase(projectsService.upsertAcceptance)
const updateSolution = upsertPhase(projectsService.updateSolution)
const moveStage = async (request, reply) => {
const project = await projectsService.moveProjectStage(request.params.projectCode, request.body, getCurrentUser(request))
if (!project) return error(reply, '项目不存在', 404)
return success(reply, project)
}
const rollbackStage = async (request, reply) => {
const project = await projectsService.rollbackProjectStage(request.params.projectCode, request.body.reason, getCurrentUser(request))
if (!project) return error(reply, '项目不存在', 404)
return success(reply, project)
}
const remove = async (request, reply) => {
const project = await projectsService.deleteProject(request.params.projectCode)
if (!project) return error(reply, '项目不存在', 404)
return success(reply, project)
}
const listTeam = async (request, reply) => {
const project = await projectRolesService.getProjectByCode(request.params.projectCode)
if (!project) return error(reply, '项目不存在', 404)
return success(reply, { assignments: await projectRolesService.listAssignments(project.id) })
}
const replaceTeam = async (request, reply) => {
const project = await projectRolesService.getProjectByCode(request.params.projectCode)
if (!project) return error(reply, '项目不存在', 404)
const assignments = await projectRolesService.replaceAssignments(project.id, request.body.assignments, request.user)
return success(reply, { assignments })
}
export default async function dmsProjectsRoutes(fastify) {
// 本文件所有路由都属于 /dms/projects 权限范围,统一校验
fastify.addHook('preHandler', requireRouteAccess('/dms/projects'))
fastify.post(
'/projects',
{ schema: { body: projectCreateBody } },
create,
)
fastify.get(
'/projects',
{ schema: { querystring: projectsListQuery } },
list,
)
fastify.get(
'/projects/:projectCode',
{
schema: { params: projectCodeParam },
preHandler: [
requireProjectAccess,
],
},
detail,
)
fastify.put(
'/projects/:projectCode',
{
schema: { params: projectCodeParam, body: projectUpdateBody },
preHandler: [
requireProjectRole('project_manager'),
],
},
update,
)
fastify.delete(
'/projects/:projectCode',
{
schema: { params: projectCodeParam },
preHandler: [
requireRole('admin'),
],
},
remove,
)
fastify.put(
'/projects/:projectCode/stage',
{
schema: { params: projectCodeParam, body: moveStageBody },
preHandler: [
requireProjectAccess,
],
},
moveStage,
)
fastify.put(
'/projects/:projectCode/stage/rollback',
{
schema: { params: projectCodeParam, body: rollbackStageBody },
preHandler: [
requireProjectRole('project_manager'),
],
},
rollbackStage,
)
fastify.put(
'/projects/:projectCode/solution',
{
schema: { params: projectCodeParam, body: solutionUpsertBody },
preHandler: [
requireProjectRole('solution_owner', 'project_manager'),
],
},
updateSolution,
)
fastify.get(
'/projects/:projectCode/team',
{
schema: { params: projectCodeParam },
preHandler: [
requireProjectAccess,
],
},
listTeam,
)
fastify.put(
'/projects/:projectCode/team',
{
schema: { params: projectCodeParam, body: projectTeamBody },
preHandler: [
requireProjectRole('project_manager'),
],
},
replaceTeam,
)
fastify.put(
'/projects/:projectCode/initiation',
{
schema: { params: projectCodeParam, body: initiationUpsertBody },
preHandler: [
requireProjectRole('initiation_owner', 'project_manager'),
],
},
upsertInitiation,
)
fastify.put(
'/projects/:projectCode/procurement',
{
schema: { params: projectCodeParam, body: procurementUpsertBody },
preHandler: [
requireProjectRole('procurement_owner', 'project_manager'),
],
},
upsertProcurement,
)
fastify.put(
'/projects/:projectCode/contract',
{
schema: { params: projectCodeParam, body: contractUpsertBody },
preHandler: [
requireProjectRole('contract_owner', 'project_manager'),
],
},
upsertContract,
)
fastify.put(
'/projects/:projectCode/delivery',
{
schema: { params: projectCodeParam, body: deliveryUpsertBody },
preHandler: [
requireProjectRole('delivery_owner', 'project_manager'),
],
},
upsertDelivery,
)
fastify.put(
'/projects/:projectCode/acceptance',
{
schema: { params: projectCodeParam, body: acceptanceUpsertBody },
preHandler: [
requireProjectRole('acceptance_owner', 'project_manager'),
],
},
upsertAcceptance,
)
}
import { UAParser } from 'ua-parser-js' import { UAParser } from 'ua-parser-js'
import * as logsService from './logs.service.js' import * as logsService from '#src/services/logs.service.js'
import { success } from '../../lib/response.js' import { success } from '#src/lib/response.js'
import {
createBatchBody,
createLogBody,
queryLogsQuery,
statsQuery,
} from '#src/schemas/logs.js'
// 解析 userAgent 获取设备信息 // 解析 userAgent 获取设备信息
const parseDevice = (userAgent) => { const parseDevice = (userAgent) => {
...@@ -18,46 +24,53 @@ const parseDevice = (userAgent) => { ...@@ -18,46 +24,53 @@ const parseDevice = (userAgent) => {
} }
} }
export const create = async (req, res) => { const create = async (request, reply) => {
const userAgent = req.get('user-agent') const userAgent = request.headers['user-agent']
const logData = { const logData = {
...req.body, ...request.body,
ip: req.ip, ip: request.ip,
userAgent, userAgent,
device: parseDevice(userAgent), device: parseDevice(userAgent),
} }
const log = await logsService.create(logData) const log = await logsService.create(logData)
success(res, log) return success(reply, log)
} }
export const createBatch = async (req, res) => { const createBatch = async (request, reply) => {
const { logs } = req.body const { logs } = request.body
const userAgent = req.get('user-agent') const userAgent = request.headers['user-agent']
const device = parseDevice(userAgent) const device = parseDevice(userAgent)
const enrichedLogs = logs.map((log) => ({ const enrichedLogs = logs.map((log) => ({
...log, ...log,
ip: req.ip, ip: request.ip,
userAgent, userAgent,
device, device,
})) }))
const result = await logsService.createBatch(enrichedLogs) const result = await logsService.createBatch(enrichedLogs)
success(res, { inserted: result.length }) return success(reply, { inserted: result.length })
} }
export const query = async (req, res) => { const query = async (request, reply) => {
const { type, level, appName, userId, startTime, endTime, keyword, page, limit } = req.query const { type, level, appName, userId, startTime, endTime, keyword, page, limit } = request.query
const result = await logsService.query( const result = await logsService.query(
{ type, level, appName, userId, startTime, endTime, keyword }, { type, level, appName, userId, startTime, endTime, keyword },
{ page, limit }, { page, limit },
) )
success(res, result) return success(reply, result)
} }
export const getStats = async (req, res) => { const getStats = async (request, reply) => {
const { appName } = req.query const { appName } = request.query
const stats = await logsService.getStats({ appName }) const stats = await logsService.getStats({ appName })
success(res, stats) return success(reply, stats)
}
export default async function logsRoutes(fastify) {
fastify.post('/logs', { schema: { body: createLogBody } }, create)
fastify.post('/logs/batch', { schema: { body: createBatchBody } }, createBatch)
fastify.get('/logs', { schema: { querystring: queryLogsQuery } }, query)
fastify.get('/logs/stats', { schema: { querystring: statsQuery } }, getStats)
} }
import { z } from 'zod'
const healthSchema = z.object({ status: z.literal('ok'), timestamp: z.number() })
const readySchema = z.object({
status: z.enum(['ok', 'degraded']),
checks: z.object({ mysql: z.boolean(), mongo: z.boolean() }),
})
export default async function rootRoutes(fastify) {
// 存活探针:进程还在就返回 ok
fastify.get(
'/health',
{ schema: { response: { 200: healthSchema }, hide: true } },
async () => ({ status: 'ok', timestamp: Date.now() }),
)
// 就绪探针:实际探测依赖(MySQL ping + Mongo 连接状态)
fastify.get(
'/health/ready',
{ schema: { response: { 200: readySchema, 503: readySchema }, hide: true } },
async (request, reply) => {
const checks = { mysql: false, mongo: false }
try {
const [rows] = await fastify.mysql.query('SELECT 1 AS ok')
// bigNumberStrings: true 会让 SELECT 1 返回字符串 '1',统一转数字比较
checks.mysql = Number(rows[0]?.ok) === 1
} catch (err) {
request.log.warn({ err }, 'readiness: mysql check failed')
}
const state = fastify.mongoose.connection.readyState
checks.mongo = state === 1
const ready = checks.mysql && checks.mongo
return reply.code(ready ? 200 : 503).send({ status: ready ? 'ok' : 'degraded', checks })
},
)
}
import * as wechatService from '#src/services/wechat.service.js'
import { getInfoBody, getSignatureBody, shareBody } from '#src/schemas/wechat.js'
// 微信接口入参保持驼峰(appId),多个前端(ezijing-pages 等)已在用,不做契约变更
export default async function wechatRoutes(fastify) {
fastify.post('/share/getsignature', { schema: { body: getSignatureBody } }, async (request, reply) => {
const { appId, url } = request.body
const ticket = await wechatService.getCachedTicket(appId)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (url || request.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
return reply.send({
url: rawUrl,
ticket: ticket.jsapi_ticket,
token: signature,
noncestr: ticket.noncestr,
timestamp: ticket.timestamp,
})
})
fastify.post('/share/token', { schema: { body: shareBody } }, async (request, reply) => {
const { appId } = request.body
const ticket = await wechatService.getCachedTicket(appId)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (request.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
return reply.send({ token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp })
})
fastify.post('/getInfo', { schema: { body: getInfoBody } }, async (request, reply) => {
const { appId, code } = request.body
const userInfo = await wechatService.getUserInfo(appId, code)
return reply.send(userInfo)
})
}
import { z } from 'zod'
import * as service from '#src/services/wx-chart.service.js'
const getValue = async (request, reply) => {
const key = request.params['*']
if (!key) return reply.code(400).send({ success: false, error: { message: 'Key is required' } })
const value = await service.getValue(key)
return reply.send({ success: true, data: { code: value } })
}
const setValue = async (request, reply) => {
const key = request.params['*']
if (!key) return reply.code(400).send({ success: false, error: { message: 'Key is required' } })
const data = await service.setValue(key, request.query.val || 0)
return reply.send({ success: true, data })
}
export default async function wxChartRoutes(fastify) {
fastify.get(
'/get/wx-chart/*',
{ schema: { response: { 200: z.object({ success: z.literal(true), data: z.object({ code: z.number() }) }) } } },
getValue,
)
// set 返回整份存储对象(键动态),无法用固定 response schema,保持 JSON.stringify 序列化
fastify.get('/set/wx-chart/*', setValue)
}
import { z } from 'zod'
import { optionalId, pagination } from './common.js'
export const casesListQuery = z.object({
keyword: z.string().optional(),
product_id: z.coerce.number().int().optional(),
...pagination,
})
const caseBody = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().nullish(),
product_id: optionalId,
product_name: z.string().max(255).nullish(),
files: z.array(z.unknown()).nullish(),
})
export const caseCreateBody = caseBody
export const caseUpdateBody = caseBody
import { z } from 'zod'
export const pagination = {
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
}
export const optionalNumber = z.preprocess(
(value) => value === null || value === '' ? undefined : value,
z.coerce.number().optional(),
)
export const optionalId = z.preprocess(
(value) => value === '' ? undefined : value,
z.coerce.number().int().positive().nullish(),
)
export const fileListValue = z.string().nullish()
export const idParam = z.object({
id: z.coerce.number().int().positive(),
})
import { z } from 'zod'
import { pagination } from './common.js'
export const productsListQuery = z.object({
keyword: z.string().optional(),
status: z.coerce.number().int().optional(),
...pagination,
})
const productBody = z.object({
name: z.string().trim().min(1).max(120),
status: z.coerce.number().int().optional(),
description: z.string().trim().nullish(),
})
export const productCreateBody = productBody
export const productUpdateBody = productBody
export const productUpdateStatusBody = z.object({
status: z.number().int(),
})
import { z } from 'zod'
import { fileListValue, optionalNumber, pagination } from './common.js'
export const projectsListQuery = z.object({
keyword: z.string().optional(),
stage: z.coerce.number().int().optional(),
status: z.coerce.number().int().optional(),
...pagination,
})
export const projectCodeParam = z.object({
projectCode: z.string().min(1).max(64),
})
export const projectCreateBody = z.object({
name: z.string().trim().min(1).max(255),
province: z.string().trim().min(1).max(120),
city: z.string().trim().min(1).max(120),
school_name: z.string().trim().min(1).max(255),
department_name: z.string().max(255).nullish(),
product_id: z.coerce.number().int().positive(),
product_name: z.string().max(255).nullish(),
contact_name: z.string().max(120).nullish(),
contact_title: z.string().max(120).nullish(),
contact_phone: z.string().max(64).nullish(),
description: z.string().nullish(),
})
export const projectUpdateBody = projectCreateBody.partial()
export const solutionUpsertBody = z.object({
solution: z.string().nullish(),
})
export const moveStageBody = z.object({
to_stage: z.coerce.number().int(),
description: z.string().nullish(),
})
export const rollbackStageBody = z.object({
reason: z.string().min(1).max(500),
})
export const projectTeamBody = z.object({
assignments: z.array(z.object({
sso_id: z.string().min(1).max(64),
user_name: z.string().max(120).optional(),
mobile: z.string().max(64).optional(),
role: z.enum([
'project_manager', 'solution_owner', 'initiation_owner', 'procurement_owner',
'contract_owner', 'delivery_owner', 'acceptance_owner', 'viewer',
]),
})),
})
// ── phase upserts ────────────────────────────────────────────────────
export const initiationUpsertBody = z.object({
application_file_url: fileListValue,
argument_file_url: fileListValue,
project_amount: optionalNumber,
fund_source: z.string().max(255).nullish(),
execution_plan: z.string().nullish(),
expected_tender_time: z.string().max(120).nullish(),
})
export const procurementUpsertBody = z.object({
tender_owner: z.string().max(120).nullish(),
business_params: z.string().nullish(),
technical_params: z.string().nullish(),
intention_publicity: z.string().max(255).nullish(),
main_bid_owner: z.string().max(120).nullish(),
companion_bidders: z.string().nullish(),
formal_bid_status: z.string().max(120).nullish(),
winning_notice_file_url: fileListValue,
bid_archive_file_url: fileListValue,
})
export const contractUpsertBody = z.object({
contract_no: z.string().max(128).nullish(),
contract_name: z.string().max(255).nullish(),
amount: optionalNumber,
drafter: z.string().max(120).nullish(),
archive_file_url: fileListValue,
})
export const deliveryUpsertBody = z.object({
delivery_address: z.string().max(255).nullish(),
delivery_contact: z.string().max(120).nullish(),
delivery_contact_phone: z.string().max(64).nullish(),
delivery_note: z.string().nullish(),
})
export const acceptanceUpsertBody = z.object({
acceptance_report_url: fileListValue,
acceptance_note: z.string().nullish(),
})
import axios from 'axios' import axios from 'axios'
import config from '../../../config.js' import config from '#src/config.js'
import { getUserAuthorization } from './permission.client.js' import { createCachedFetcher } from '#src/lib/cached-fetch.js'
import { getUserAuthorization } from '#src/clients/permission.client.js'
import { hasAnyDmsRouteAccess, normalizeRoute } from './route-access.js' import { hasAnyDmsRouteAccess, normalizeRoute } from './route-access.js'
const roleTag = (role) => role?.tag const roleTag = (role) => role?.tag
const userCache = new Map() // 用户信息取数器:TTL + 容量上限 + 并发去重
const userRequests = new Map() const getSsoUser = createCachedFetcher({ ttlMs: config.dmsAuth.userInfoCacheTtlSeconds * 1000 })
export const getTgc = (req) => { export const getTgc = (req) => {
const cookies = req.headers.cookie || '' const cookies = req.headers.cookie || ''
...@@ -46,27 +47,6 @@ const fetchSsoUser = async (tgc) => { ...@@ -46,27 +47,6 @@ const fetchSsoUser = async (tgc) => {
return user return user
} }
const getSsoUser = async (tgc) => {
const cached = userCache.get(tgc)
if (cached && cached.expiresAt > Date.now()) return cached.value
const pending = userRequests.get(tgc)
if (pending) return pending
const request = fetchSsoUser(tgc)
.then((user) => {
userCache.set(tgc, {
value: user,
expiresAt: Date.now() + config.dmsAuth.userInfoCacheTtlSeconds * 1000,
})
return user
})
.finally(() => userRequests.delete(tgc))
userRequests.set(tgc, request)
return request
}
export const authenticateRequest = async (req) => { export const authenticateRequest = async (req) => {
const tgc = getTgc(req) const tgc = getTgc(req)
if (!tgc) { if (!tgc) {
...@@ -75,7 +55,7 @@ export const authenticateRequest = async (req) => { ...@@ -75,7 +55,7 @@ export const authenticateRequest = async (req) => {
throw error throw error
} }
const user = await getSsoUser(tgc) const user = await getSsoUser(tgc, () => fetchSsoUser(tgc))
let authorization let authorization
try { try {
...@@ -102,3 +82,11 @@ export const authenticateRequest = async (req) => { ...@@ -102,3 +82,11 @@ export const authenticateRequest = async (req) => {
} }
return authenticatedUser return authenticatedUser
} }
// 从已认证的 request.user 中提取操作人信息(写入 operator_user_id / operator_name)
export const getCurrentUser = (request) => ({
userId: request.user?.ssoId || null,
userName: request.user?.name || null,
mobile: request.user?.mobile || null,
roles: request.user?.roles || [],
})
import { and, count, desc, eq, like, or } from 'drizzle-orm' import { and, count, desc, eq, like, or } from 'drizzle-orm'
import config from '../../../config.js' import config from '#src/config.js'
import { httpError } from '../../../lib/http-error.js' import { httpError } from '#src/lib/http-error.js'
import { uploadJson } from '../../../lib/oss.js' import { uploadJson } from '#src/clients/oss.client.js'
import { db } from '../../../db/index.js' import { db } from '#src/db/client.js'
import { cases } from './schema.js' import { cases } from '#src/db/schema/cases.js'
import { products } from '../products/schema.js' import { products } from '#src/db/schema/products.js'
const parseFiles = (row) => { const parseFiles = (row) => {
if (!row) return row if (!row) return row
...@@ -17,37 +17,37 @@ const parseFiles = (row) => { ...@@ -17,37 +17,37 @@ const parseFiles = (row) => {
const rowsToList = (rows) => rows.map((row) => parseFiles({ const rowsToList = (rows) => rows.map((row) => parseFiles({
...row.caseRecord, ...row.caseRecord,
productName: row.productName || '', product_name: row.product_name || '',
})) }))
const toPublicCase = (record) => ({ const toPublicCase = (record) => ({
id: record.id, id: record.id,
name: record.name, name: record.name,
description: record.description || '', description: record.description || '',
product_id: record.productId ?? null, product_id: record.product_id ?? null,
product_name: record.productName || '', product_name: record.product_name || '',
files: record.files || [], files: record.files || [],
operator: { operator: {
id: record.operatorUserId || '', id: record.operator_user_id || '',
name: record.operatorName || '', name: record.operator_name || '',
}, },
created_at: record.createdAt, created_at: record.created_at,
updated_at: record.updatedAt, updated_at: record.updated_at,
}) })
const resolveOperator = (currentUser = {}) => ({ const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null, operator_user_id: currentUser.userId || null,
operatorName: currentUser.userName || null, operator_name: currentUser.userName || null,
}) })
const buildFilters = (filters = {}) => { const buildFilters = (filters = {}) => {
const conditions = [] const conditions = []
if (filters.keyword) { if (filters.keyword) {
const keyword = `%${filters.keyword}%` const keyword = `%${filters.keyword}%`
conditions.push(or(like(cases.name, keyword), like(cases.description, keyword), like(cases.operatorName, keyword))) conditions.push(or(like(cases.name, keyword), like(cases.description, keyword), like(cases.operator_name, keyword)))
} }
if (filters.productId) { if (filters.product_id) {
conditions.push(eq(cases.productId, Number(filters.productId))) conditions.push(eq(cases.product_id, Number(filters.product_id)))
} }
return conditions.length ? and(...conditions) : undefined return conditions.length ? and(...conditions) : undefined
...@@ -61,11 +61,11 @@ export const listCases = async (filters = {}, options = {}) => { ...@@ -61,11 +61,11 @@ export const listCases = async (filters = {}, options = {}) => {
const [list, totalRows] = await Promise.all([ const [list, totalRows] = await Promise.all([
db db
.select({ caseRecord: cases, productName: products.name }) .select({ caseRecord: cases, product_name: products.name })
.from(cases) .from(cases)
.leftJoin(products, eq(cases.productId, products.id)) .leftJoin(products, eq(cases.product_id, products.id))
.where(where) .where(where)
.orderBy(desc(cases.updatedAt)) .orderBy(desc(cases.updated_at))
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
db.select({ total: count() }).from(cases).where(where), db.select({ total: count() }).from(cases).where(where),
...@@ -85,13 +85,13 @@ export const listCases = async (filters = {}, options = {}) => { ...@@ -85,13 +85,13 @@ export const listCases = async (filters = {}, options = {}) => {
export const syncCasesToPublic = async () => { export const syncCasesToPublic = async () => {
const rows = await db const rows = await db
.select({ caseRecord: cases, productName: products.name }) .select({ caseRecord: cases, product_name: products.name })
.from(cases) .from(cases)
.leftJoin(products, eq(cases.productId, products.id)) .leftJoin(products, eq(cases.product_id, products.id))
.orderBy(desc(cases.id)) .orderBy(desc(cases.id))
const payload = rows.map((row) => toPublicCase(parseFiles({ const payload = rows.map((row) => toPublicCase(parseFiles({
...row.caseRecord, ...row.caseRecord,
productName: row.productName || '', product_name: row.product_name || '',
}))) })))
const upload = await uploadJson(config.oss.caseJsonObjectKey, payload) const upload = await uploadJson(config.oss.caseJsonObjectKey, payload)
...@@ -103,16 +103,16 @@ export const syncCasesToPublic = async () => { ...@@ -103,16 +103,16 @@ export const syncCasesToPublic = async () => {
export const getCaseById = async (id) => { export const getCaseById = async (id) => {
const rows = await db const rows = await db
.select({ caseRecord: cases, productName: products.name }) .select({ caseRecord: cases, product_name: products.name })
.from(cases) .from(cases)
.leftJoin(products, eq(cases.productId, products.id)) .leftJoin(products, eq(cases.product_id, products.id))
.where(eq(cases.id, Number(id))) .where(eq(cases.id, Number(id)))
.limit(1) .limit(1)
if (!rows[0]) return null if (!rows[0]) return null
return parseFiles({ return parseFiles({
...rows[0].caseRecord, ...rows[0].caseRecord,
productName: rows[0].productName || '', product_name: rows[0].product_name || '',
}) })
} }
......
import { httpError } from '#src/lib/http-error.js'
import { authenticateRequest } from './auth.service.js'
import { hasAnyDmsRouteAccess, hasRouteAccess } from './route-access.js'
import { getProjectByCode, userHasAnyProjectRole } from './project-roles.service.js'
export const authenticate = async (request) => {
request.user = await authenticateRequest(request)
}
export const requireAnyDmsRouteAccess = async (request) => {
if (hasAnyDmsRouteAccess(request.user)) return
throw httpError(403, '无权访问 DMS 模块')
}
export const requireRouteAccess = (routePrefix) => async (request) => {
if (hasRouteAccess(request.user, routePrefix)) return
throw httpError(403, '无权访问当前 DMS 模块')
}
export const hasRole = (user, role) => user?.roles?.includes('admin') || user?.roles?.includes(role)
export const requireRole = (...roles) => async (request) => {
// hasRole 内部已包含 admin 判断
if (roles.some((role) => hasRole(request.user, role))) return
throw httpError(403, '当前角色无权执行该操作')
}
export const requireProjectRole = (...roles) => async (request) => {
if (request.user?.roles?.includes('admin')) return
const project = await getProjectByCode(request.params.projectCode)
if (!project) throw httpError(404, '项目不存在')
const allowed = await userHasAnyProjectRole(project.id, request.user.ssoId, roles)
if (!allowed) throw httpError(403, '您不是当前项目的授权负责人')
request.project = project
}
export const requireProjectAccess = async (request) => {
if (request.user?.roles?.includes('admin')) return
const project = await getProjectByCode(request.params.projectCode)
if (!project) throw httpError(404, '项目不存在')
const allowed = await userHasAnyProjectRole(project.id, request.user?.ssoId)
if (!allowed) throw httpError(403, '您不是该项目成员')
request.project = project
}
import { and, count, desc, eq, like, or } from 'drizzle-orm' import { and, count, desc, eq, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js' import { httpError } from '#src/lib/http-error.js'
import { db } from '../../../db/index.js' import { db } from '#src/db/client.js'
import { cases } from '../cases/schema.js' import { cases } from '#src/db/schema/cases.js'
import { projects } from '../projects/schema.js' import { projects } from '#src/db/schema/projects.js'
import { products, PRODUCT_STATUSES } from './schema.js' import { products, PRODUCT_STATUSES } from '#src/db/schema/products.js'
const resolveOperator = (currentUser = {}) => ({ const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null, operator_user_id: currentUser.userId || null,
operatorName: currentUser.userName || null, operator_name: currentUser.userName || null,
}) })
const buildFilters = (filters = {}) => { const buildFilters = (filters = {}) => {
const conditions = [] const conditions = []
if (filters.keyword) { if (filters.keyword) {
const keyword = `%${filters.keyword}%` const keyword = `%${filters.keyword}%`
conditions.push(or(like(products.name, keyword), like(products.description, keyword), like(products.operatorName, keyword))) conditions.push(or(like(products.name, keyword), like(products.description, keyword), like(products.operator_name, keyword)))
} }
if (filters.status !== undefined && filters.status !== '') { if (filters.status !== undefined && filters.status !== '') {
conditions.push(eq(products.status, Number(filters.status))) conditions.push(eq(products.status, Number(filters.status)))
...@@ -30,7 +30,7 @@ export const listProducts = async (filters = {}, options = {}) => { ...@@ -30,7 +30,7 @@ export const listProducts = async (filters = {}, options = {}) => {
const where = buildFilters(filters) const where = buildFilters(filters)
const [list, totalRows] = await Promise.all([ const [list, totalRows] = await Promise.all([
db.select().from(products).where(where).orderBy(desc(products.updatedAt)).limit(limit).offset(offset), db.select().from(products).where(where).orderBy(desc(products.updated_at)).limit(limit).offset(offset),
db.select({ total: count() }).from(products).where(where), db.select({ total: count() }).from(products).where(where),
]) ])
const total = totalRows[0]?.total || 0 const total = totalRows[0]?.total || 0
...@@ -46,7 +46,7 @@ export const listProducts = async (filters = {}, options = {}) => { ...@@ -46,7 +46,7 @@ export const listProducts = async (filters = {}, options = {}) => {
} }
} }
export const getProductById = async (id) => { const getProductById = async (id) => {
const rows = await db.select().from(products).where(eq(products.id, Number(id))).limit(1) const rows = await db.select().from(products).where(eq(products.id, Number(id))).limit(1)
return rows[0] || null return rows[0] || null
...@@ -103,8 +103,8 @@ export const deleteProduct = async (id) => { ...@@ -103,8 +103,8 @@ export const deleteProduct = async (id) => {
const product = await getProductById(id) const product = await getProductById(id)
if (!product) return null if (!product) return null
const [projectRows, caseRows] = await Promise.all([ const [projectRows, caseRows] = await Promise.all([
db.select({ total: count() }).from(projects).where(eq(projects.productId, Number(id))), db.select({ total: count() }).from(projects).where(eq(projects.product_id, Number(id))),
db.select({ total: count() }).from(cases).where(eq(cases.productId, Number(id))), db.select({ total: count() }).from(cases).where(eq(cases.product_id, Number(id))),
]) ])
if (projectRows[0]?.total || caseRows[0]?.total) { if (projectRows[0]?.total || caseRows[0]?.total) {
throw httpError(400, '产品已被项目或案例使用,不能删除') throw httpError(400, '产品已被项目或案例使用,不能删除')
......
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import { db } from '../../../db/index.js' import { db } from '#src/db/client.js'
import { httpError } from '../../../lib/http-error.js' import { httpError } from '#src/lib/http-error.js'
import { projectTimelines, projects } from '../projects/schema.js' import { projectTimelines, projects } from '#src/db/schema/projects.js'
import { PROJECT_ROLES, projectRoleAssignments } from './schema.js' import { PROJECT_ROLES, projectRoleAssignments } from '#src/db/schema/project-roles.js'
const ACTIVE = 1 const ACTIVE = 1
export const getProjectByCode = async (projectCode) => { export const getProjectByCode = async (projectCode) => {
const rows = await db.select().from(projects).where(eq(projects.projectCode, projectCode)).limit(1) const rows = await db.select().from(projects).where(eq(projects.project_code, projectCode)).limit(1)
return rows[0] || null return rows[0] || null
} }
export const listAssignments = async (projectId) => db export const listAssignments = async (projectId) => db
.select() .select()
.from(projectRoleAssignments) .from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.projectId, projectId), eq(projectRoleAssignments.status, ACTIVE))) .where(and(eq(projectRoleAssignments.project_id, projectId), eq(projectRoleAssignments.status, ACTIVE)))
export const getUserProjectRoles = async (projectId, ssoId) => {
if (!ssoId) return []
const rows = await db
.select({ role: projectRoleAssignments.role })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.projectId, projectId),
eq(projectRoleAssignments.ssoId, ssoId),
eq(projectRoleAssignments.status, ACTIVE),
))
return rows.map((row) => row.role)
}
export const replaceAssignments = async (projectId, assignments = [], currentUser = {}) => { export const replaceAssignments = async (projectId, assignments = [], currentUser = {}) => {
const normalized = assignments.map((item) => ({ const normalized = assignments.map((item) => ({
ssoId: String(item.ssoId), sso_id: String(item.sso_id),
userName: item.userName?.trim() || null, user_name: item.user_name?.trim() || null,
mobile: item.mobile?.trim() || null, mobile: item.mobile?.trim() || null,
role: item.role, role: item.role,
})) }))
...@@ -45,7 +32,7 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse ...@@ -45,7 +32,7 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const projectRows = await tx.select({ const projectRows = await tx.select({
projectCode: projects.projectCode, project_code: projects.project_code,
stage: projects.stage, stage: projects.stage,
status: projects.status, status: projects.status,
}).from(projects).where(eq(projects.id, projectId)).limit(1) }).from(projects).where(eq(projects.id, projectId)).limit(1)
...@@ -53,37 +40,37 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse ...@@ -53,37 +40,37 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse
if (!project) throw httpError(404, '项目不存在') if (!project) throw httpError(404, '项目不存在')
const existing = await tx.select({ const existing = await tx.select({
ssoId: projectRoleAssignments.ssoId, sso_id: projectRoleAssignments.sso_id,
userName: projectRoleAssignments.userName, user_name: projectRoleAssignments.user_name,
mobile: projectRoleAssignments.mobile, mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role, role: projectRoleAssignments.role,
status: projectRoleAssignments.status, status: projectRoleAssignments.status,
}).from(projectRoleAssignments).where(eq(projectRoleAssignments.projectId, projectId)) }).from(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, projectId))
const signature = (items) => items const signature = (items) => items
.map((item) => [item.ssoId, item.userName || '', item.mobile || '', item.role, item.status ?? 1].join('|')) .map((item) => [item.sso_id, item.user_name || '', item.mobile || '', item.role, item.status ?? 1].join('|'))
.sort() .sort()
.join(';') .join(';')
const changed = signature(existing) !== signature(normalized) const changed = signature(existing) !== signature(normalized)
await tx.delete(projectRoleAssignments).where(eq(projectRoleAssignments.projectId, projectId)) await tx.delete(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, projectId))
if (normalized.length) { if (normalized.length) {
await tx.insert(projectRoleAssignments).values(normalized.map((item) => ({ await tx.insert(projectRoleAssignments).values(normalized.map((item) => ({
projectId, project_id: projectId,
...item, ...item,
assignedBySsoId: currentUser.ssoId, assigned_by_sso_id: currentUser.ssoId,
}))) })))
} }
if (changed) { if (changed) {
await tx.insert(projectTimelines).values({ await tx.insert(projectTimelines).values({
projectId, project_id: projectId,
projectCode: project.projectCode, project_code: project.project_code,
title: '项目团队已更新', title: '项目团队已更新',
description: '项目角色分工已更新', description: '项目角色分工已更新',
stage: project.stage, stage: project.stage,
status: project.status, status: project.status,
operatorUserId: currentUser.ssoId || null, operator_user_id: currentUser.ssoId || null,
operatorName: currentUser.name || null, operator_name: currentUser.name || null,
}) })
} }
}) })
...@@ -92,8 +79,8 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse ...@@ -92,8 +79,8 @@ export const replaceAssignments = async (projectId, assignments = [], currentUse
export const userHasAnyProjectRole = async (projectId, ssoId, allowedRoles = []) => { export const userHasAnyProjectRole = async (projectId, ssoId, allowedRoles = []) => {
const conditions = [ const conditions = [
eq(projectRoleAssignments.projectId, projectId), eq(projectRoleAssignments.project_id, projectId),
eq(projectRoleAssignments.ssoId, ssoId), eq(projectRoleAssignments.sso_id, ssoId),
eq(projectRoleAssignments.status, ACTIVE), eq(projectRoleAssignments.status, ACTIVE),
] ]
if (allowedRoles.length) conditions.push(inArray(projectRoleAssignments.role, allowedRoles)) if (allowedRoles.length) conditions.push(inArray(projectRoleAssignments.role, allowedRoles))
......
import { and, count, desc, eq, inArray } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import {
acceptances,
contracts,
deliveries,
initiations,
procurements,
projects,
PROJECT_STATUSES,
PROJECT_STAGES,
projectTimelines,
} from '#src/db/schema/projects.js'
import { products } from '#src/db/schema/products.js'
import { projectRoleAssignments } from '#src/db/schema/project-roles.js'
import { isProjectDeleteAllowed } from '#src/services/dms/workflow.js'
import {
buildProjectCode,
resolveOperator,
withProjectOwnerFields,
toNumberOrUndefined,
pickDefined,
STAGE_OWNER_ROLES,
PROJECT_OWNER_ROLES,
assertProjectBaseEditable,
} from './helpers.js'
import {
getProjectByCodeWithDb,
resolveProductFields,
appendTimeline,
buildProjectFilters,
getUserProjectRoles,
} from './db-helpers.js'
export const createProject = async (payload = {}, currentUser = {}) => {
const projectCode = payload.project_code || buildProjectCode()
await db.transaction(async (tx) => {
const productFields = await resolveProductFields(tx, payload)
const [insertResult] = await tx.insert(projects).values({
project_code: projectCode,
name: payload.name,
province: payload.province || null,
city: payload.city || null,
school_name: payload.school_name || null,
department_name: payload.department_name || null,
product_id: productFields.product_id || null,
product_name: productFields.product_name || null,
contact_name: payload.contact_name || null,
contact_title: payload.contact_title || null,
contact_phone: payload.contact_phone || null,
solution: payload.solution || null,
stage: toNumberOrUndefined(payload.stage) ?? PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
description: payload.description || null,
...resolveOperator(currentUser),
})
const project = await getProjectByCodeWithDb(tx, projectCode)
const projectId = insertResult.insertId || project.id
if (currentUser.userId) {
await tx.insert(projectRoleAssignments).values({
project_id: projectId,
sso_id: currentUser.userId,
user_name: currentUser.userName || null,
mobile: currentUser.mobile || null,
role: 'project_manager',
assigned_by_sso_id: currentUser.userId,
})
}
await tx.insert(initiations).values({ project_id: projectId })
await tx.insert(procurements).values({ project_id: projectId })
await tx.insert(contracts).values({ project_id: projectId })
await tx.insert(deliveries).values({ project_id: projectId })
await tx.insert(acceptances).values({ project_id: projectId })
await appendTimeline(tx, { ...project, id: projectId }, {
title: '创建项目',
description: '项目已创建,进入方案阶段',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
operatorName: currentUser.userName || null,
operatorUserId: currentUser.userId || null,
})
})
return getProjectDetail(projectCode)
}
export const listProjects = async (filters = {}, options = {}, currentUser = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
let projectIds
if (!currentUser.roles?.includes('admin')) {
const rows = await db.select({ project_id: projectRoleAssignments.project_id })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.sso_id, currentUser.userId), eq(projectRoleAssignments.status, 1)))
projectIds = rows.map((row) => row.project_id)
if (!projectIds.length) return { list: [], pagination: { page, limit, total: 0, pages: 0 } }
}
const where = buildProjectFilters({ ...filters, projectIds })
const [projectRows, totalRows] = await Promise.all([
db
.select({ project: projects, product_name: products.name })
.from(projects)
.leftJoin(products, eq(projects.product_id, products.id))
.where(where)
.orderBy(desc(projects.created_at))
.limit(limit)
.offset(offset),
db.select({ total: count() }).from(projects).where(where),
])
const projectIdsInPage = projectRows.map((row) => row.project.id)
const assignmentRows = projectIdsInPage.length
? await db.select({
project_id: projectRoleAssignments.project_id,
sso_id: projectRoleAssignments.sso_id,
user_name: projectRoleAssignments.user_name,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
inArray(projectRoleAssignments.project_id, projectIdsInPage),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
: []
const assignmentsByProjectId = new Map()
for (const assignment of assignmentRows) {
const current = assignmentsByProjectId.get(assignment.project_id) || []
current.push(assignment)
assignmentsByProjectId.set(assignment.project_id, current)
}
const list = projectRows.map((row) => ({
...withProjectOwnerFields(row.project, assignmentsByProjectId.get(row.project.id)),
can_edit_project: currentUser.roles?.includes('admin')
|| (
row.project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(row.project.stage)
&& assignmentsByProjectId.get(row.project.id)?.some((item) => item.role === 'project_manager' && item.sso_id === currentUser.userId)
),
product_name: row.product_name || '',
}))
const total = totalRows[0]?.total || 0
return {
list,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
export const getProjectByCode = async (projectCode) => getProjectByCodeWithDb(db, projectCode)
export const getProjectDetail = async (projectCode) => {
const project = await getProjectByCode(projectCode)
if (!project) return null
const [
initiationRows,
procurementRows,
contractRows,
deliveryRows,
acceptanceRows,
timelineRows,
] = await Promise.all([
db.select().from(initiations).where(eq(initiations.project_id, project.id)).limit(1),
db.select().from(procurements).where(eq(procurements.project_id, project.id)).limit(1),
db.select().from(contracts).where(eq(contracts.project_id, project.id)).limit(1),
db.select().from(deliveries).where(eq(deliveries.project_id, project.id)).limit(1),
db.select().from(acceptances).where(eq(acceptances.project_id, project.id)).limit(1),
db.select().from(projectTimelines).where(eq(projectTimelines.project_id, project.id)).orderBy(desc(projectTimelines.created_at)),
])
const ownerRows = await db.select({
sso_id: projectRoleAssignments.sso_id,
user_name: projectRoleAssignments.user_name,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
eq(projectRoleAssignments.project_id, project.id),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
return {
...withProjectOwnerFields(project, ownerRows),
initiation: initiationRows[0] || {},
procurement: procurementRows[0] || {},
contract: contractRows[0] || {},
delivery: deliveryRows[0] || {},
acceptance: acceptanceRows[0] || {},
timeline: timelineRows,
}
}
export const getProjectAccess = async (project, user = {}) => {
const activeStages = Object.values(PROJECT_STAGES).filter((stage) => stage !== PROJECT_STAGES.ARCHIVED)
const isActive = project.status === PROJECT_STATUSES.ACTIVE
const editableStages = isActive && activeStages.includes(project.stage) ? [project.stage] : []
if (user?.roles?.includes('admin')) {
return {
can_edit_project: isActive && [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage),
can_manage_team: true,
can_move_stage: isActive,
can_rollback_stage: true,
editable_stages: editableStages,
}
}
const roles = await getUserProjectRoles(project.id, user?.ssoId)
const isProjectManager = roles.includes('project_manager')
const isCurrentOwner = roles.includes(STAGE_OWNER_ROLES[project.stage])
const canOperateStage = isProjectManager || isCurrentOwner
return {
can_edit_project: isProjectManager && isActive,
can_manage_team: isProjectManager,
can_move_stage: isActive && canOperateStage,
can_rollback_stage: isProjectManager,
editable_stages: canOperateStage ? editableStages : [],
}
}
export const updateProject = async (projectCode, payload = {}, currentUser = {}) => {
const values = pickDefined(payload, [
'name',
'province',
'city',
'school_name',
'department_name',
'contact_name',
'contact_title',
'contact_phone',
'description',
])
Object.assign(values, await resolveProductFields(db, payload))
if (Object.keys(values).length) {
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
assertProjectBaseEditable(project)
await tx.update(projects).set({ ...values, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
}
return getProjectDetail(projectCode)
}
export const deleteProject = async (projectCode) => {
let removedProject = null
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
if (!isProjectDeleteAllowed({ stage: project.stage, status: project.status })) {
throw httpError(400, '仅方案/立项阶段的进行中项目允许删除')
}
await tx.delete(projects).where(eq(projects.id, project.id))
removedProject = project
})
return removedProject
}
import { and, eq, inArray, like, or } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import {
acceptances,
contracts,
initiations,
projects,
projectTimelines,
PROJECT_STAGES,
} from '#src/db/schema/projects.js'
import { products } from '#src/db/schema/products.js'
import { projectRoleAssignments } from '#src/db/schema/project-roles.js'
import {
STAGE_LABELS,
STATUS_LABELS,
PROJECT_OWNER_ROLES,
toNumberOrUndefined,
hasFiles,
} from './helpers.js'
export const getProjectByCodeWithDb = async (db, projectCode) => {
const rows = await db
.select({ project: projects, product_name: products.name })
.from(projects)
.leftJoin(products, eq(projects.product_id, products.id))
.where(eq(projects.project_code, projectCode))
.limit(1)
if (!rows[0]) return null
return {
...rows[0].project,
product_name: rows[0].product_name || '',
}
}
export const resolveProductFields = async (db, payload = {}) => {
const productId = toNumberOrUndefined(payload.product_id)
if (productId === undefined) {
return payload.product_name !== undefined ? { product_name: payload.product_name || null } : {}
}
const rows = await db.select().from(products).where(eq(products.id, productId)).limit(1)
const product = rows[0]
if (!product) {
throw httpError(400, '产品不存在')
}
return {
product_id: productId,
product_name: product.name,
}
}
export const appendTimeline = async (db, project, { title, description, stage, status, operatorUserId, operatorName }) => {
await db.insert(projectTimelines).values({
project_id: project.id,
project_code: project.project_code,
title,
description: description || null,
stage: stage ?? project.stage,
status: status ?? project.status,
operator_user_id: operatorUserId || null,
operator_name: operatorName || null,
})
}
const appendStageTimeline = async (db, project, stage, status, description, options = {}) => {
await appendTimeline(db, project, {
title: `${STAGE_LABELS[stage] || '项目阶段'}${STATUS_LABELS[status] ? ` - ${STATUS_LABELS[status]}` : ''}`,
description,
stage,
status,
operatorUserId: options.operatorUserId,
operatorName: options.operatorName,
})
}
const buildProjectFilters = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
const matchedOwnerProjects = db
.select({ project_id: projectRoleAssignments.project_id })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
or(
like(projectRoleAssignments.user_name, keyword),
like(projectRoleAssignments.mobile, keyword),
),
))
conditions.push(or(
like(projects.project_code, keyword),
like(projects.name, keyword),
like(projects.school_name, keyword),
like(projects.operator_name, keyword),
inArray(projects.id, matchedOwnerProjects),
))
}
const stage = toNumberOrUndefined(filters.stage)
const status = toNumberOrUndefined(filters.status)
if (stage !== undefined) conditions.push(eq(projects.stage, stage))
if (status !== undefined) conditions.push(eq(projects.status, status))
if (filters.projectIds) conditions.push(inArray(projects.id, filters.projectIds))
return conditions.length ? and(...conditions) : undefined
}
const getOrCreatePhaseRow = async (tx, table, projectId) => {
const rows = await tx.select().from(table).where(eq(table.project_id, projectId)).limit(1)
if (rows[0]) return rows[0]
const [result] = await tx.insert(table).values({ project_id: projectId })
return { id: result.insertId, project_id: projectId }
}
const getUserProjectRoles = async (projectId, ssoId, database = db) => {
if (!ssoId) return []
const rows = await database.select({ role: projectRoleAssignments.role })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.project_id, projectId), eq(projectRoleAssignments.sso_id, ssoId), eq(projectRoleAssignments.status, 1)))
return rows.map((row) => row.role)
}
const validateStageCompletion = async (tx, project) => {
if (project.stage === PROJECT_STAGES.SOLUTION && !project.solution?.trim()) {
throw httpError(400, '方案完成前请填写方案内容')
}
if (project.stage === PROJECT_STAGES.INITIATION) {
const rows = await tx.select({ application_file_url: initiations.application_file_url }).from(initiations).where(eq(initiations.project_id, project.id)).limit(1)
if (!hasFiles(rows[0]?.application_file_url)) throw httpError(400, '立项完成前请上传项目申报书')
}
if (project.stage === PROJECT_STAGES.CONTRACT) {
const rows = await tx.select().from(contracts).where(eq(contracts.project_id, project.id)).limit(1)
const contract = rows[0]
if (!contract?.contract_no?.trim() || !contract.contract_name?.trim() || !hasFiles(contract.archive_file_url)) {
throw httpError(400, '合同完成前请填写合同编号、合同名称并上传合同文件')
}
}
if (project.stage === PROJECT_STAGES.ACCEPTANCE) {
const rows = await tx.select({ acceptance_report_url: acceptances.acceptance_report_url }).from(acceptances).where(eq(acceptances.project_id, project.id)).limit(1)
if (!hasFiles(rows[0]?.acceptance_report_url)) throw httpError(400, '验收完成前请上传验收报告')
}
}
export {
appendStageTimeline,
buildProjectFilters,
getOrCreatePhaseRow,
getUserProjectRoles,
validateStageCompletion,
}
import { httpError } from '#src/lib/http-error.js'
import { PROJECT_STATUSES, PROJECT_STAGES } from '#src/db/schema/projects.js'
export const STAGE_LABELS = {
[PROJECT_STAGES.SOLUTION]: '方案阶段',
[PROJECT_STAGES.INITIATION]: '立项阶段',
[PROJECT_STAGES.PROCUREMENT]: '招投标阶段',
[PROJECT_STAGES.CONTRACT]: '合同阶段',
[PROJECT_STAGES.DELIVERY]: '交付阶段',
[PROJECT_STAGES.ACCEPTANCE]: '验收阶段',
[PROJECT_STAGES.ARCHIVED]: '归档',
}
export const STATUS_LABELS = {
[PROJECT_STATUSES.ACTIVE]: '进行中',
[PROJECT_STATUSES.ARCHIVED]: '已归档',
}
export const STAGE_OWNER_ROLES = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
export const PROJECT_OWNER_ROLES = ['project_manager', ...Object.values(STAGE_OWNER_ROLES)]
export const withProjectOwnerFields = (project, assignments = []) => {
const projectManagers = assignments.filter((item) => item.role === 'project_manager')
const stageOwners = assignments.filter((item) => item.role === STAGE_OWNER_ROLES[project.stage])
return {
...project,
project_manager_sso_ids: projectManagers.map((item) => item.sso_id),
project_manager_name: projectManagers.map((item) => item.user_name || item.sso_id).join('、') || null,
current_stage_owner_name: stageOwners.map((item) => item.user_name || item.sso_id).join('、') || null,
current_stage_owner_mobile: stageOwners.map((item) => item.mobile).filter(Boolean).join('、') || null,
current_stage_owner_sso_ids: stageOwners.map((item) => item.sso_id),
}
}
export const buildProjectCode = () => {
const date = new Date()
const ymd = date.toISOString().slice(0, 10).replaceAll('-', '')
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase()
return `DMS${ymd}${suffix}`
}
export const toNumberOrUndefined = (value) => {
if (value === undefined || value === null || value === '') return undefined
const parsed = Number(value)
return Number.isNaN(parsed) ? undefined : parsed
}
export const pickDefined = (payload, fields) =>
fields.reduce((values, field) => {
if (payload[field] !== undefined) values[field] = payload[field]
return values
}, {})
const FILE_URL_FIELDS = new Set([
'application_file_url',
'argument_file_url',
'winning_notice_file_url',
'bid_archive_file_url',
'archive_file_url',
'acceptance_report_url',
])
const normalizeFileUrls = (value) => {
if (value === null || value === '') return ''
let files
try {
files = JSON.parse(value)
} catch {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
if (!Array.isArray(files) || files.some((file) => (
!file
|| typeof file.url !== 'string' || !file.url
|| typeof file.size !== 'string'
|| typeof file.name !== 'string' || !file.name
|| typeof file.type !== 'string'
))) {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
return JSON.stringify(files.map(({ url, size, name, type }) => ({ url, size, name, type })))
}
export const hasFiles = (value) => {
if (!value) return false
try {
const files = JSON.parse(value)
return Array.isArray(files) && files.length > 0
} catch {
return false
}
}
export const normalizeFileUrlFields = (values) => {
for (const field of Object.keys(values)) {
if (FILE_URL_FIELDS.has(field)) values[field] = normalizeFileUrls(values[field])
}
return values
}
export const assertProjectStageEditable = (project, stage) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改阶段资料')
if (project.stage !== stage) throw httpError(409, '只能修改当前阶段资料,请先退回到该阶段')
}
export const assertProjectBaseEditable = (project) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改基础信息')
if (![PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage)) {
throw httpError(409, '进入后续阶段后不可修改项目基础信息')
}
}
export const resolveOperator = (currentUser = {}) => ({
operator_user_id: currentUser.userId || null,
operator_name: currentUser.userName || null,
})
export {
createProject,
listProjects,
getProjectByCode,
getProjectDetail,
getProjectAccess,
updateProject,
deleteProject,
} from './crud.js'
export {
updateSolution,
upsertInitiation,
upsertProcurement,
upsertContract,
upsertDelivery,
upsertAcceptance,
} from './phases.js'
export { moveProjectStage, rollbackProjectStage } from './stages.js'
export { appendTimeline } from './db-helpers.js'
import { eq } from 'drizzle-orm'
import { db } from '#src/db/client.js'
import { acceptances, contracts, deliveries, initiations, procurements, projects, PROJECT_STAGES } from '#src/db/schema/projects.js'
import { pickDefined, normalizeFileUrlFields, assertProjectStageEditable, resolveOperator } from './helpers.js'
import { getProjectByCodeWithDb, getOrCreatePhaseRow } from './db-helpers.js'
import { getProjectDetail } from './crud.js'
export const updateSolution = async (projectCode, payload = {}, currentUser = {}) => {
const solution = typeof payload.solution === 'string' ? payload.solution : ''
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
assertProjectStageEditable(project, PROJECT_STAGES.SOLUTION)
await tx.update(projects).set({ solution, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
return exists ? getProjectDetail(projectCode) : null
}
// 阶段表里可被接口更新的字段 = 表的所有列减去系统列(校验边界仍由 zod schema 把关)
const SYSTEM_FIELDS = new Set(['id', 'project_id', 'created_at', 'updated_at', 'completed_at'])
const editableFields = (table) =>
Object.entries(table)
.filter(([key, col]) => col && typeof col === 'object' && col.name && col.dataType && !SYSTEM_FIELDS.has(key))
.map(([key]) => key)
const upsertPhase = async (projectCode, table, payload, stage, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
assertProjectStageEditable(project, stage)
const row = await getOrCreatePhaseRow(tx, table, project.id)
const values = normalizeFileUrlFields(pickDefined(payload, editableFields(table)))
if (Object.keys(values).length) {
await tx.update(table).set(values).where(eq(table.id, row.id))
await tx.update(projects).set(resolveOperator(currentUser)).where(eq(projects.id, project.id))
}
})
return exists ? getProjectDetail(projectCode) : null
}
export const upsertInitiation = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, initiations, payload, PROJECT_STAGES.INITIATION, currentUser)
export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, procurements, payload, PROJECT_STAGES.PROCUREMENT, currentUser)
export const upsertContract = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, contracts, payload, PROJECT_STAGES.CONTRACT, currentUser)
export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, deliveries, payload, PROJECT_STAGES.DELIVERY, currentUser)
export const upsertAcceptance = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, acceptances, payload, PROJECT_STAGES.ACCEPTANCE, currentUser)
import { eq } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import { deliveries, projects, PROJECT_STAGES, PROJECT_STATUSES } from '#src/db/schema/projects.js'
import { isManualTransitionAllowed } from '#src/services/dms/workflow.js'
import { toNumberOrUndefined, resolveOperator } from './helpers.js'
import { getProjectByCodeWithDb, getUserProjectRoles, validateStageCompletion, appendStageTimeline } from './db-helpers.js'
import { getProjectDetail } from './crud.js'
export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => {
const stage = toNumberOrUndefined(payload.to_stage)
if (stage === undefined) {
throw httpError(400, 'to_stage 不能为空')
}
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
const status = stage === PROJECT_STAGES.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE
const matchedTransition = isManualTransitionAllowed({
fromStage: project.stage,
fromStatus: project.status,
toStage: stage,
toStatus: status,
})
if (!matchedTransition) throw httpError(400, '不允许的阶段流转')
const isAdministrator = currentUser.roles?.includes('admin')
if (!isAdministrator) {
const roleByStage = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager') && !roles.includes(roleByStage[project.stage])) {
throw httpError(403, '您不是当前阶段负责人')
}
}
if (project.stage === PROJECT_STAGES.DELIVERY) {
await tx.update(deliveries)
.set({ completed_at: new Date() })
.where(eq(deliveries.project_id, project.id))
}
await validateStageCompletion(tx, project)
await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, stage, status, payload.description || '已完成当前阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
export const rollbackProjectStage = async (projectCode, reason, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
if (!currentUser.roles?.includes('admin')) {
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager')) throw httpError(403, '只有项目负责人和管理员可以退回阶段')
}
const previousStage = {
[PROJECT_STAGES.INITIATION]: PROJECT_STAGES.SOLUTION,
[PROJECT_STAGES.PROCUREMENT]: PROJECT_STAGES.INITIATION,
[PROJECT_STAGES.CONTRACT]: PROJECT_STAGES.PROCUREMENT,
[PROJECT_STAGES.DELIVERY]: PROJECT_STAGES.CONTRACT,
[PROJECT_STAGES.ACCEPTANCE]: PROJECT_STAGES.DELIVERY,
[PROJECT_STAGES.ARCHIVED]: PROJECT_STAGES.ACCEPTANCE,
}[project.stage]
if (!previousStage) throw httpError(400, '当前阶段不能退回')
await tx.update(projects).set({ stage: previousStage, status: PROJECT_STATUSES.ACTIVE, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, previousStage, PROJECT_STATUSES.ACTIVE, reason || '项目退回上一阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
...@@ -4,7 +4,7 @@ export const normalizeRoute = (value) => { ...@@ -4,7 +4,7 @@ export const normalizeRoute = (value) => {
return normalized || '/' return normalized || '/'
} }
export const routeMatches = (allowedRoute, requestPath) => { const routeMatches = (allowedRoute, requestPath) => {
const allowed = normalizeRoute(allowedRoute) const allowed = normalizeRoute(allowedRoute)
const requested = normalizeRoute(requestPath) const requested = normalizeRoute(requestPath)
return requested === allowed || requested.startsWith(`${allowed}/`) return requested === allowed || requested.startsWith(`${allowed}/`)
......
import { PROJECT_STATUSES, PROJECT_STAGES } from './schema.js' import { PROJECT_STATUSES, PROJECT_STAGES } from '#src/db/schema/projects.js'
export const MANUAL_STAGE_TRANSITIONS = [ const MANUAL_STAGE_TRANSITIONS = [
{ {
fromStage: PROJECT_STAGES.INITIATION, fromStage: PROJECT_STAGES.INITIATION,
fromStatuses: [PROJECT_STATUSES.ACTIVE], fromStatuses: [PROJECT_STATUSES.ACTIVE],
...@@ -38,7 +38,7 @@ export const MANUAL_STAGE_TRANSITIONS = [ ...@@ -38,7 +38,7 @@ export const MANUAL_STAGE_TRANSITIONS = [
}, },
] ]
export const PROJECT_DELETE_RULE = { const PROJECT_DELETE_RULE = {
stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION], stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION],
statuses: [PROJECT_STATUSES.ACTIVE], statuses: [PROJECT_STATUSES.ACTIVE],
} }
......
import Log from './logs.model.js' import Log from '#src/models/log.model.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const TEXT_API_PATTERN = '/api/volcano/api/v3/chat/completions' const TEXT_API_PATTERN = '/api/volcano/api/v3/chat/completions'
......
import crypto from 'crypto' import crypto from 'crypto'
import axios from 'axios' import axios from 'axios'
import config from '../../config.js' import config from '#src/config.js'
import { httpError } from '../../lib/http-error.js' import { httpError } from '#src/lib/http-error.js'
let ticketCache = { jsapi_ticket: '', noncestr: '', timestamp: '', expiresAt: 0 } let ticketCache = { jsapi_ticket: '', noncestr: '', timestamp: '', expiresAt: 0 }
const CACHE_DURATION = 60 * 60 * 1000 // 1 hour const CACHE_DURATION = 60 * 60 * 1000 // 1 hour
export const getAccessToken = async (appId) => { const getAccessToken = async (appId) => {
const secret = config.wechat.apps[appId] const secret = config.wechat.apps[appId]
if (!secret) throw httpError(500, `WeChat app secret not found for appId: ${appId}`) if (!secret) throw httpError(500, `WeChat app secret not found for appId: ${appId}`)
...@@ -18,7 +18,7 @@ export const getAccessToken = async (appId) => { ...@@ -18,7 +18,7 @@ export const getAccessToken = async (appId) => {
return data.access_token return data.access_token
} }
export const getJsapiTicket = async (appId) => { const getJsapiTicket = async (appId) => {
const accessToken = await getAccessToken(appId) const accessToken = await getAccessToken(appId)
const { data } = await axios.get('https://api.weixin.qq.com/cgi-bin/ticket/getticket', { const { data } = await axios.get('https://api.weixin.qq.com/cgi-bin/ticket/getticket', {
params: { type: 'jsapi', access_token: accessToken }, params: { type: 'jsapi', access_token: accessToken },
......
import path from 'path' import path from 'path'
import config from '../../config.js' import config from '#src/config.js'
import { readJson, writeJson } from '../../lib/file.js' import { readJson, writeJson } from '#src/lib/file.js'
const DATA_FILE = path.join(config.dataDir, 'wxchart-success.json') const DATA_FILE = path.join(config.dataDir, 'wxchart-success.json')
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论