提交 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",
"version": "2.0.0",
"description": "ezijing-node-server - Modern Node.js API server",
"description": "ezijing-node-server - Fastify API server",
"private": true,
"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": {
"start": "node src/index.js",
"dev": "cross-env NODE_ENV=development node --watch src/index.js",
"db:generate": "drizzle-kit generate",
"lint": "eslint src/",
"lint:fix": "eslint --fix src/",
"deploy": "cross-env NODE_ENV=production SERVER_PORT=5101 DOTENV_CONFIG_PATH=.env.prod pm2 start src/index.js -n ezijing-node-server",
"reload": "pm2 reload ezijing-node-server"
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"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",
"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",
"license": "ISC",
"engines": {
"node": ">=18.0.0"
"node": ">=20.19.0"
},
"devDependencies": {
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0",
"globals": "^15.12.0",
"pino-pretty": "^13.1.3"
"eslint": "^10.10.0",
"globals": "^17.12.0"
},
"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",
"axios": "^1.15.2",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"axios": "^1.20.0",
"dayjs": "^1.11.19",
"dotenv": "^16.4.5",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"express-http-proxy": "^2.1.2",
"mongoose": "^8.23.1",
"fastify": "^5.12.3",
"fastify-plugin": "^6.0.0",
"fastify-type-provider-zod": "^7.0.0",
"lru-cache": "^11.5.2",
"mongoose": "^9.9.5",
"mysql2": "^3.20.0",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"ua-parser-js": "^2.0.6",
"zod": "^4.4.3"
}
......
import express from 'express'
import cors from 'cors'
import proxy from 'express-http-proxy'
import pinoHttp from 'pino-http'
import logger from './lib/logger.js'
import { notFound, errorHandler } from './middleware/error.js'
import wechatRoutes from './modules/wechat/wechat.routes.js'
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'
const app = express()
// Trust proxy
app.set('trust proxy', true)
// Request logging
app.use(pinoHttp({ logger, autoLogging: process.env.NODE_ENV !== 'test' }))
// Middleware
app.use(cors({ credentials: true }))
app.use('/api/usercenter', proxy('https://api-usercenter.ezijing.com'))
// 增加请求体大小限制,支持批量日志上报(默认 100kb,增加到 10mb)
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now() }))
// Modules
app.use(wechatRoutes)
app.use(wxChartRoutes)
app.use('/api', logsRoutes)
app.use('/api', dmsRoutes)
// Error handling
app.use(notFound)
app.use(errorHandler)
export default app
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import autoload from '@fastify/autoload'
import { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod'
import { error } from './lib/response.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* 根插件(Fastify 官方推荐结构):
* plugins/ 共享插件,使用 fastify-plugin 包装,装饰器/编译器对全局可见
* routes/ 路由插件,目录名即路由前缀;autohooks.js 为同级及子级路由挂钩子
*
* 错误处理器在这里(根上下文)设置,并且必须在加载 routes 之前,
* 子插件在注册时继承父级的 error handler。
*/
export default async function app(fastify, opts) {
fastify.setNotFoundHandler((request, reply) => {
return error(reply, '接口未定义', 404, { path: request.url, method: request.method })
})
fastify.setErrorHandler((err, request, reply) => {
if (hasZodFastifySchemaValidationErrors(err)) {
const message = err.validation
.map((e) => {
const p = e.instancePath.replace(/^\//, '').replaceAll('/', '.')
return `${p ? p + ': ' : ''}${e.message}`
})
.join('; ')
return error(reply, message, 400, { details: err.validation })
}
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
if (statusCode >= 500) {
request.log.error({ err, url: request.url, method: request.method }, 'Request error')
} else {
// 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 config from '../config.js'
import config from '#src/config.js'
let client
......
import crypto from 'node:crypto'
import axios from 'axios'
import config from '../../../config.js'
import { createPermissionSignature } from './permission-sign.js'
import config from '#src/config.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'
const cache = new Map()
const requests = new Map()
// 权限取数器:TTL + 容量上限 + 并发去重
const getAuthorization = createCachedFetcher({ ttlMs: config.dmsAuth.permissionCacheTtlSeconds * 1000 })
const unwrapItems = (payload) => payload?.data?.items ?? []
......@@ -37,33 +39,17 @@ const requestRaw = async (path, tgc, params = {}) => {
const request = async (path, tgc, params = {}) => unwrapItems(await requestRaw(path, tgc, params))
export const getUserAuthorization = async (ssoId, tgc) => {
const cached = cache.get(ssoId)
if (cached && cached.expiresAt > Date.now()) return cached.value
const pending = requests.get(ssoId)
if (pending) return pending
const requestPromise = Promise.all([
export const getUserAuthorization = (ssoId, tgc) =>
getAuthorization(ssoId, async () => {
const [roles, routes] = await Promise.all([
request('/api/v1/user/roles', tgc),
request('/api/v1/user/routes', tgc),
])
.then(([roles, routes]) => {
const value = {
return {
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 = {}) => {
const payload = await requestRaw('/api/v1/app/users', tgc, params)
......@@ -75,7 +61,7 @@ export const getApplicationUsers = async (tgc, params = {}) => {
try {
profiles = await getUserProfiles(items.map((item) => item.sso_id))
} catch (error) {
console.warn('用户中心批量查询失败,成员列表将保留权限中心数据', error.message)
logger.warn({ err: error, count: items.length }, '用户中心批量查询失败,成员列表将保留权限中心数据')
}
return {
......
import crypto from 'node:crypto'
import axios from 'axios'
import config from '../../../config.js'
import config from '#src/config.js'
const MAX_BATCH_SIZE = 500
......@@ -34,7 +34,7 @@ export const getUserProfiles = async (ssoIds) => {
if (!ids.length) return {}
if (!userCenterBatchUrl || !userCenterSalt) {
console.warn('用户中心批量查询未配置 USER_CENTER_BATCH_URL 或 USER_CENTER_SALT')
logger.warn('用户中心批量查询未配置 USER_CENTER_SALT,成员列表将缺少姓名(检查 .env)')
return {}
}
......
import 'dotenv/config'
import fs from 'node:fs'
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 = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101,
dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'),
env: env.NODE_ENV,
version: process.env.APP_VERSION || pkg.version,
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: {
uri: process.env.MONGODB_URI || '',
uri: env.MONGODB_URI,
maxPoolSize: env.MONGO_MAX_POOL_SIZE,
},
mysql: {
url: process.env.DATABASE_URL || '',
host: process.env.MYSQL_HOST || '127.0.0.1',
port: parseInt(process.env.MYSQL_PORT || '3306', 10) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'com_dms',
url: env.DATABASE_URL,
host: env.MYSQL_HOST,
port: env.MYSQL_PORT,
user: env.MYSQL_USER,
password: env.MYSQL_PASSWORD,
database: env.MYSQL_DATABASE,
poolSize: env.MYSQL_POOL_SIZE,
},
oss: {
region: process.env.OSS_REGION || 'oss-cn-beijing',
bucket: process.env.OSS_BUCKET || 'webapp-pub',
accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
endpoint: process.env.OSS_ENDPOINT || '',
caseJsonObjectKey: process.env.CASE_JSON_OBJECT_KEY || 'case_library/case.json',
region: env.OSS_REGION,
bucket: env.OSS_BUCKET,
accessKeyId: env.OSS_ACCESS_KEY_ID,
accessKeySecret: env.OSS_ACCESS_KEY_SECRET,
endpoint: env.OSS_ENDPOINT,
caseJsonObjectKey: env.CASE_JSON_OBJECT_KEY,
},
dmsAuth: {
ssoUserInfoUrl:
process.env.SSO_USER_INFO_URL || 'https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info',
userCenterBatchUrl:
process.env.USER_CENTER_BATCH_URL || 'https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info',
userCenterSalt: process.env.USER_CENTER_SALT || '',
permissionApiUrl: process.env.PERMISSION_API_URL || '',
permissionAppId: process.env.PERMISSION_APP_ID || '',
permissionAppSecret: process.env.PERMISSION_APP_SECRET || '',
userInfoCacheTtlSeconds: parseInt(process.env.SSO_USER_CACHE_TTL_SECONDS || '180', 10) || 180,
permissionCacheTtlSeconds: parseInt(process.env.PERMISSION_CACHE_TTL_SECONDS || '180', 10) || 180,
ssoUserInfoUrl: env.SSO_USER_INFO_URL,
userCenterBatchUrl: env.USER_CENTER_BATCH_URL,
userCenterSalt: env.USER_CENTER_SALT,
permissionApiUrl: env.PERMISSION_API_URL,
permissionAppId: env.PERMISSION_APP_ID,
permissionAppSecret: env.PERMISSION_APP_SECRET,
userInfoCacheTtlSeconds: env.SSO_USER_CACHE_TTL_SECONDS,
permissionCacheTtlSeconds: env.PERMISSION_CACHE_TTL_SECONDS,
},
wechat: {
apps: {
......
import { drizzle } from 'drizzle-orm/mysql2'
import { getMysqlPool } from '../lib/mysql.js'
import { getMysqlPool } from '#src/lib/mysql.js'
export const db = drizzle(getMysqlPool())
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(
'case_list',
......@@ -7,15 +7,15 @@ export const cases = mysqlTable(
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
productId: bigint('product_id', { mode: 'number', unsigned: true }),
productName: varchar('product_name', { length: 255 }),
product_id: bigint('product_id', { mode: 'number', unsigned: true }),
product_name: varchar('product_name', { length: 255 }),
files: longtext('files'),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
productIdIdx: index('idx_cases_product_id').on(table.productId),
operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operatorUserId),
productIdIdx: index('idx_cases_product_id').on(table.product_id),
operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operator_user_id),
})
)
......@@ -2,8 +2,8 @@ import { datetime } from 'drizzle-orm/mysql-core'
import { sql } from 'drizzle-orm'
export const timestamps = {
createdAt: datetime('created_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at', { mode: 'string' })
created_at: datetime('created_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
updated_at: datetime('updated_at', { mode: 'string' })
.notNull()
.default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
}
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 = {
DISABLED: 0,
......@@ -13,13 +13,13 @@ export const products = mysqlTable(
name: varchar('name', { length: 120 }).notNull(),
status: int('status').notNull().default(PRODUCT_STATUSES.ENABLED),
description: text('description'),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
nameIdx: uniqueIndex('uk_products_name').on(table.name),
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 { sql } from 'drizzle-orm'
import { timestamps } from '../db/columns.js'
import { projects } from '../projects/schema.js'
import { timestamps } from './columns.js'
import { projects } from './projects.js'
export const PROJECT_ROLES = [
'project_manager',
......@@ -16,20 +16,20 @@ export const PROJECT_ROLES = [
export const projectRoleAssignments = mysqlTable('project_role_assignments', {
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()
.references(() => projects.id, { onDelete: 'cascade' }),
ssoId: varchar('sso_id', { length: 64 }).notNull(),
userName: varchar('user_name', { length: 120 }),
sso_id: varchar('sso_id', { length: 64 }).notNull(),
user_name: varchar('user_name', { length: 120 }),
mobile: varchar('mobile', { length: 64 }),
role: varchar('role', { length: 32 }).notNull(),
status: tinyint('status', { unsigned: true }).notNull().default(1),
assignedBySsoId: varchar('assigned_by_sso_id', { length: 64 }),
assignedAt: datetime('assigned_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
assigned_by_sso_id: varchar('assigned_by_sso_id', { length: 64 }),
assigned_at: datetime('assigned_at', { mode: 'string' }).notNull().default(sql`CURRENT_TIMESTAMP`),
...timestamps,
}, (table) => ({
projectIdx: index('idx_project_roles_project_id').on(table.projectId),
userIdx: index('idx_project_roles_sso_id').on(table.ssoId),
projectRoleIdx: index('idx_project_roles_project_role').on(table.projectId, table.role),
assignmentIdx: uniqueIndex('uk_project_roles_assignment').on(table.projectId, table.ssoId, table.role),
projectIdx: index('idx_project_roles_project_id').on(table.project_id),
userIdx: index('idx_project_roles_sso_id').on(table.sso_id),
projectRoleIdx: index('idx_project_roles_project_role').on(table.project_id, table.role),
assignmentIdx: uniqueIndex('uk_project_roles_assignment').on(table.project_id, table.sso_id, table.role),
}))
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论