提交 6a6e6197 authored 作者: 王鹏飞's avatar 王鹏飞

chore: 工程化与文档

- CLAUDE.md:目录/命名规范、硬性约定、已知取舍 - README:部署流程(Jenkins → 镜像 → K8s)、API 字段命名规则 - scripts/db-status.mjs:只读的迁移状态检查(npm run db:status) - config 用 zod 启动校验;engines 提到 >=20.19;.env.example 补齐变量 - 移除 .github(CI 走 Jenkins)
上级 f69f0b6f
NODE_ENV=development
SERVER_PORT=4101
DATA_DIR=../node-server-data
LOG_LEVEL=
# 生产默认关闭 /docs,需要时设为 true
ENABLE_DOCS=
MONGODB_URI=
MONGO_MAX_POOL_SIZE=10
DATABASE_URL=
MYSQL_HOST=127.0.0.1
......@@ -8,6 +14,7 @@ MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=com_dms
MYSQL_POOL_SIZE=10
OSS_REGION=oss-cn-beijing
OSS_BUCKET=webapp-pub
......
# ezijing-node-server
Fastify 5 (ESM, Node >= 20) API 服务。DMS 交付管理、微信服务、图表数据、日志采集,
后续承载 AI 网关 / agent 中转(含用量统计与配额控制)。
## 常用命令
```bash
npm run dev # 开发(node --watch)
npm test # node:test + app.inject,无需数据库、无需 .env
npm run lint # eslint src/ test/ scripts/
npm run db:status # 只读检查迁移状态(先跑这个,再决定是否 db:migrate)
npm run db:generate # drizzle-kit 生成迁移
```
## 目录结构
```
src/
├── index.js 进程入口:创建实例、listen、信号处理
├── app.js 根插件:错误处理 + autoload(plugins/) + autoload(routes/)
├── config.js 环境变量(zod 启动校验,缺配置立刻失败)
├── plugins/ Fastify 插件(autoload,一文件一个 fastify-plugin)
├── db/
│ ├── client.js Drizzle 实例
│ └── schema/ Drizzle 表定义(一表族一文件)
├── models/ Mongoose 模型
├── clients/ 外部系统封装(OSS、权限中心、用户中心、微信 HTTP)
├── lib/ 纯函数工具(logger、response、http-error、file、sign)
├── services/ 业务逻辑,按域分包
├── schemas/ zod 契约,与 routes 同构
└── routes/ 路由(autoload,目录名 = URL 前缀)
test/
├── helper.js build():构建 app 实例供 inject
├── unit/ 纯函数 / 服务单测
└── routes/ HTTP 集成测试
```
## 命名规则
| 类别 | 位置 | 规则 | 示例 |
|---|---|---|---|
| 路由 | `routes/**` | kebab-case,资源复数,文件名 = URL 段 | `products.js` → `/products` |
| 路由钩子 | `routes/<域>/autohooks.js` | 固定名,autoload 自动作用于同目录路由 | `dms/autohooks.js` |
| 业务钩子 | `services/<域>/hooks.js` | **禁止放 routes/**(会被 autoload 当路由注册) | `dms/hooks.js` |
| 服务 | `services/<域>/<资源>.service.js` | 复数资源 + `.service` | `products.service.js` |
| 服务包 | `services/<域>/<资源>/` | 超 300 行拆包,`index.js` 只做 re-export | `projects/index.js` |
| 外部客户端 | `clients/<系统>.client.js` | 只封装协议,不含业务判断 | `permission.client.js` |
| 纯工具 | `lib/<用途>.js` | 无 IO、无状态 | `sign.js` |
| 插件 | `plugins/<能力>.js` | 一文件一插件,`fastify-plugin` 包装 | `zod.js` |
| Drizzle 表 | `db/schema/<表族>.js` | 复数;非表文件显式命名 | `projects.js`、`columns.js` |
| Mongoose 模型 | `models/<模型>.model.js` | 单数,与模型名一致 | `log.model.js` |
| zod 契约 | `schemas/<域>/<资源>.js` | 与 routes 同构 | `dms/products.js` |
| 测试 | `test/{unit,routes}/**` | 镜像 src 结构 | `test/unit/hooks.test.js` |
## 硬性约定
- **所有钩子必须是 async 函数**(同步钩子会让请求挂起)。
- `setErrorHandler` / `setNotFoundHandler` 在 `src/app.js` 中、**加载 routes 之前**设置(子插件注册时继承)。
- `routes/` 下**不要建 index.js**:autoload 见到 index 会跳过同级路由文件。
- 校验一律用路由 `schema: { body, querystring, params }` + zod(`fastify-type-provider-zod`),
不使用 preHandler 手写校验。
- `schema.response` 只在响应形状完全可控时声明——fast-json-stringify 会**裁掉未声明字段**。
数据库行透传的端点(DMS、logs)暂不声明;新接口(AI 网关)从一开始就带 response schema。
- 跨目录引用用子路径别名 `#src/...`(package.json `imports` 字段),例如 `#src/lib/response.js`;
同目录/相邻文件用相对路径。
- **字段名与数据源保持一致,不做大小写转换**:
- DMS(MySQL):DB 列是下划线 → zod schema、Drizzle 属性、请求/响应字段**全部**下划线,
包括代码算出来的派生字段(`can_edit_project`、`project_manager_name`、`editable_stages`);
- 只有**函数名、局部变量、内部对象**保持驼峰(如 `workflow.ts` 的 `canMoveStage()` 函数、
`const editableStages` 局部变量)——这些是代码风格,不是 API 契约;
- logs(Mongo):文档字段是驼峰 → 接口保持驼峰(`appName`、`userId`、`createdAt`);
- 微信接口(`/share/*`、`/getInfo`):入参 `appId` 保持驼峰,兼容现有前端;
- 不要引入 case-convention 之类的边界转换层(2026-09-08 已删除并明确不做)。
- 新增域时按同一套结构复制:`routes/api/<域>/` + `services/<域>/` + `schemas/<域>/` + `db/schema/<域>/`。
## 已知取舍
- **权限规则目前有三处表达**:路由 hooks(粗粒度角色拦截)、service 断言(状态校验)、
`projects/crud.js` 的 `getProjectAccess`(前端能力标志)。**已知漂移**:
`can_edit_project` 少了阶段判断——项目负责人在非「方案/立项」阶段会看到可点、
但提交返回 409 的「编辑」按钮。收敛思路:抽 `projects/policy.js` 纯函数 + 矩阵一致性测试,
详见 2026-09-08 的讨论(暂缓实施)。
- `src/config.js` 里的微信 app secret 目前硬编码(P0 未处理,历史遗留)。
- `.env.prod` 已被 git 跟踪且随镜像发布(`Dockerfile` 的 `COPY ./` + deploy 脚本指向它)。
2026-09-08 明确决定**保持现状**:镜像只在内网阿里云 registry,外部无法拉取。不要再提议移除。
- DMS 授权类钩子(requireRole / requireProjectRole)运行在 schema 校验之后,
认证(autohooks 的 onRequest)在最前。
......@@ -17,31 +17,45 @@ npm start
## Project Structure
Fastify 官方推荐结构([fastify-cli 模板](https://github.com/fastify/fastify-cli)):`plugins/` 放共享插件,`routes/` 按目录自动挂载(目录名即路由前缀)。
```
src/
├── index.js # Entry point
├── app.js # Express app
├── config.js # Configuration
├── lib/ # Shared utilities
│ ├── logger.js # Pino logger
│ ├── mongo.js # MongoDB connection
│ └── file.js # File utilities
├── middleware/ # Global middleware
├── db/ # MySQL Drizzle runtime client
└── modules/ # Feature modules
├── dms/ # DMS app
├── wechat/ # WeChat SDK
├── wx-chart/ # WeChat chart data
└── logs/ # Log collection
├── index.js # 进程入口:创建实例、listen、信号处理
├── app.js # 根插件:错误处理 + autoload plugins/ 与 routes/
├── config.js # 配置
├── lib/ # 纯工具(logger、response、http-error、file、oss、连接工厂)
├── db/
│ ├── index.js # Drizzle 运行时实例
│ └── schema/ # Drizzle 表定义
├── models/ # Mongoose 模型
├── plugins/ # 共享插件(fastify-plugin 包装):cors、zod、swagger、
│ # mysql、mongo、formbody、sensible、usercenter-proxy
├── schemas/ # 路由 zod schema
├── services/ # 业务逻辑(不依赖 fastify 实例)
│ └── dms/ # DMS 服务、hooks(鉴权/授权)、外部客户端
└── routes/ # 路由插件,目录即前缀
├── root.js # /health
├── wechat.js # /share/*、/getInfo
├── wx-chart.js # /get|set/wx-chart/*
└── api/
├── logs.js # /api/logs*
└── dms/ # /api/dms/*(autohooks.js 统一鉴权)
```
约定:所有钩子/处理器必须是 async 函数(同步钩子会让请求挂起);`setErrorHandler` 在根插件中、
加载路由之前设置,子插件注册时继承。
## API Endpoints
### System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Health check |
| GET | `/health` | 存活探针(进程存活即 200) |
| GET | `/health/ready` | 就绪探针(ping MySQL + Mongo,异常返回 503) |
| GET | `/docs` | Swagger UI(生产默认关闭,需 `ENABLE_DOCS=true`) |
| GET | `/docs/json` | OpenAPI 文档(同上) |
### WeChat
......@@ -102,6 +116,10 @@ MONGODB_URI=
# MySQL / Drizzle
DATABASE_URL=mysql://root:password@127.0.0.1:3306/com_dms
MYSQL_POOL_SIZE=10
# MongoDB
MONGO_MAX_POOL_SIZE=10
# Legacy MySQL fallback when DATABASE_URL is not set
MYSQL_HOST=127.0.0.1
......@@ -119,6 +137,56 @@ WX_SECRET_1=your_secret
- `npm run dev` - Development with hot reload
- `npm start` - Production
- `npm test` - 运行 node:test 测试(app.inject,无需监听端口,无需数据库)
- `npm run db:migrate` - 应用 drizzle 迁移(仅适用于空库,详见 db:status)
- `npm run db:status` - 查看数据库迁移状态(只读,不改库)
- `npm run lint` - ESLint check
- `npm run lint:fix` - ESLint fix
- `npm run deploy` - Deploy to production
- `npm run deploy` - **容器内入口**(由 start.sh 调用,本机不要执行)
## API 字段命名
字段名与数据源保持一致,**不做任何大小写转换**:
- **DMS 接口**(`/api/dms/*`):MySQL 列是下划线,所以 zod schema、Drizzle 属性、
接口字段**全部** `snake_case`(`contact_name`、`product_id`、`created_at`),
**包括派生字段**(`can_edit_project`、`editable_stages`、`project_manager_name`);
只有函数名 / 局部变量等代码风格保持驼峰
- **logs 接口**(`/api/logs*`):Mongo 文档字段是驼峰,接口保持 `camelCase`
(`appName`、`userId`、`createdAt`)
- **微信接口**(`/share/*`、`/getInfo`):入参沿用 `appId`,与现有前端兼容
- 函数名、局部变量等代码风格不受影响
## 约定与说明
- **环境变量**:`src/config.js` 在启动时用 zod 校验,配置错误立即失败并指出具体变量名。
- **响应 schema**:只在响应形状完全可控的端点声明 `schema.response`(如 `/health`、`/get/wx-chart/*`)。
其余端点(DMS、logs)的 `data` 是数据库行,声明不完整会被 fast-json-stringify **裁掉未声明字段**,
因此暂不声明;新接口(如 AI 网关)应从一开始就带上 response schema。
- **压缩**:`@fastify/compress` 对超过 1KB 的响应启用 gzip/br。
- **迁移**:`migrations/*.sql` 是手工基线,`migrations/drizzle/*` 由 drizzle-kit 生成;
已有数据的库不要直接跑 `db:migrate`(生成的是普通 CREATE TABLE),先用 `db:status` 判断状态。
## 部署
生产部署不走本机命令,流程是 **Jenkins → Docker 镜像 → 阿里云 K8s**:
1. Jenkins 从 `web-shell.ezijing.com/node_server_api/` 拉取部署脚本(`init.sh` / `start.sh` / `Dockerfile`),
执行 `init.sh pro <version>`:安装生产依赖 → `docker build` → push 到
`registry.cn-beijing.aliyuncs.com/ezijing-beijing/node-server-api:v<version>`
2. 镜像基于 `node-pm2`,`CMD ["./start.sh"]`
3. 容器启动时 `start.sh` 执行 `cd /var/www/ezijing-node-server && npm run deploy`,
即 `pm2 start src/index.js`,最后 `pm2 logs` 占住前台
4. K8s 通过环境变量 / Secret 注入配置;`NODE_ENV=production` 与 `DOTENV_CONFIG_PATH=.env.prod`
由 `npm run deploy` 提供
### 容器相关的约定
- **健康探针**:存活用 `GET /health`,就绪用 `GET /health/ready`(会实际 ping MySQL/Mongo,异常返回 503)
- **优雅退出**:`src/index.js` 监听 SIGTERM/SIGINT,`app.close()` 会等在途请求;
`npm run deploy` 已加 `--kill-timeout 10000`(pm2 默认只有 1600ms,不够排空)
- **日志**:统一输出结构化 JSON(pino),便于 K8s 采集;级别用 `LOG_LEVEL` 控制,默认生产 info、开发 debug
- **版本**:`APP_VERSION` 环境变量可覆盖 `package.json` 的 version,Swagger 文档读的是这个值
- **接口文档**:`/docs` 仅在非生产环境注册;生产默认 404,需要临时查看时设 `ENABLE_DOCS=true`
- **迁移**:`drizzle-kit` 是 devDependency,运行时镜像里没有。迁移应在 Jenkins 单独执行
(先 `npm run db:status` 确认状态),不要放进容器启动命令
......@@ -5,7 +5,7 @@ const databaseUrl = process.env.DATABASE_URL
export default defineConfig({
dialect: 'mysql',
schema: './src/modules/dms/**/schema.js',
schema: './src/db/schema/**/*.js',
out: './migrations/drizzle',
dbCredentials: databaseUrl ? {
url: databaseUrl,
......
......@@ -5,7 +5,7 @@ export default [
ignores: ['node_modules/', 'build/', 'dist/'],
},
{
files: ['src/**/*.js'],
files: ['src/**/*.js', 'test/**/*.js', 'scripts/**/*.{js,mjs}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
......
......@@ -5,8 +5,11 @@
"moduleResolution": "bundler",
"baseUrl": ".",
"checkJs": false,
"resolveJsonModule": true
"resolveJsonModule": true,
"paths": {
"#src/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"include": ["src/**/*", "test/**/*", "scripts/**/*"],
"exclude": ["node_modules"]
}
#!/usr/bin/env node
/**
* 数据库迁移状态检查。
*
* 本仓库有两套 SQL:
* migrations/*.sql 手工基线(000 建库、001 建表),需人工审阅后执行
* migrations/drizzle/*.sql drizzle-kit 生成,配合 npm run db:migrate
*
* 这个脚本只读,用来判断目标库当前处于哪种状态,避免对已有数据的库
* 误跑 drizzle 迁移(生成的 SQL 是普通 CREATE TABLE,不是 IF NOT EXISTS)。
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import mysql from 'mysql2/promise'
import config from '#src/config.js'
const mask = (value) => (value ? `${String(value).slice(0, 4)}****` : '(empty)')
const run = async () => {
const connectionOptions = config.mysql.url
? { uri: config.mysql.url }
: {
host: config.mysql.host,
port: config.mysql.port,
user: config.mysql.user,
password: config.mysql.password,
database: config.mysql.database,
}
console.log(`目标库: ${config.mysql.url ? mask(config.mysql.url) : `${config.mysql.host}:${config.mysql.port}/${config.mysql.database}`}`)
const journalPath = path.resolve('migrations/drizzle/meta/_journal.json')
const journal = JSON.parse(await fs.readFile(journalPath, 'utf8'))
console.log(`drizzle 迁移条目: ${journal.entries.map((e) => e.tag).join(', ') || '(none)'}`)
const connection = await mysql.createConnection({ ...connectionOptions, connectTimeout: 5000 })
try {
const [tables] = await connection.query('SHOW TABLES')
const names = tables.map((row) => Object.values(row)[0])
console.log(`现有表 (${names.length}): ${names.join(', ') || '(empty)'}`)
let applied = []
try {
const [rows] = await connection.query('SELECT hash, created_at FROM __drizzle_migrations ORDER BY created_at')
applied = rows.map((r) => `${r.hash} @ ${r.created_at}`)
} catch {
console.log('__drizzle_migrations: 不存在')
}
if (applied.length) console.log(`已应用迁移: ${applied.join(', ')}`)
console.log('')
if (names.length === 0) {
console.log('建议: 空库 → npm run db:migrate(drizzle-kit migrate)')
} else if (!applied.length) {
console.log('建议: 已有表但未记录 drizzle 迁移,属“手工基线”状态。')
console.log(' 直接跑 db:migrate 会因 CREATE TABLE 冲突失败;新变更请用 npm run db:generate 生成后人工审阅执行。')
} else {
console.log('建议: 已纳入 drizzle 迁移管理,变更流程 = db:generate → 审阅 → db:migrate')
}
} finally {
await connection.end()
}
}
run().catch((err) => {
console.error('检查失败:', err.message)
process.exit(1)
})
import app from './src/app.js'
console.log('app loaded OK')
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论