koa3-cli 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,12 +49,20 @@ koa3-cli/
49
49
  │ │ └── user.js # 用户模型
50
50
  │ ├── middleware/ # 中间件目录
51
51
  │ │ ├── index.js # 中间件入口
52
- │ │ └── auth.js # 认证中间件示例
52
+ │ │ ├── auth.js # 认证中间件示例
53
+ │ │ ├── requestLogger.js # 请求日志中间件
54
+ │ │ └── errorHandler.js # 全局错误处理
55
+ │ ├── lib/ # 基础能力目录
56
+ │ │ ├── logger.js # 日志工具
57
+ │ │ └── validator.js # 参数校验中间件(Joi)
58
+ │ ├── schema/ # 参数校验规则目录
59
+ │ │ └── user.js # 用户相关校验规则
53
60
  │ └── router.js # 路由配置
54
61
  ├── config/ # 配置文件目录
55
62
  │ ├── config.default.js # 默认配置
56
63
  │ ├── config.local.js # 本地开发配置
57
64
  │ └── config.prod.js # 生产环境配置
65
+ ├── logs/ # 日志输出目录(运行时自动创建)
58
66
  ├── public/ # 静态资源目录
59
67
  │ └── index.html # 首页
60
68
  ├── app.js # 应用入口文件
@@ -70,7 +78,9 @@ koa3-cli/
70
78
  - ✅ 支持多环境配置(development/production)
71
79
  - ✅ MVC 架构(Controller/Service/Model)
72
80
  - ✅ 中间件支持
73
- - ✅ 统一的错误处理
81
+ - ✅ 统一的错误处理(校验失败返回 422,message 为第一条校验提示)
82
+ - ✅ 内置日志系统(访问日志、错误日志、请求追踪)
83
+ - ✅ 基于 Joi 的参数校验(body/query/params,校验结果挂到 `ctx.state.validated`)
74
84
  - ✅ RESTful API 示例
75
85
 
76
86
  ## 快速开始
@@ -109,6 +119,82 @@ npm start
109
119
 
110
120
  可以通过 `.env` 文件配置环境变量(参考 `.env.example`)。
111
121
 
122
+ ### 日志配置
123
+
124
+ 日志系统默认开启控制台和文件输出,支持 4 个级别:`debug`、`info`、`warn`、`error`。
125
+
126
+ `.env` 可配置项:
127
+
128
+ ```bash
129
+ LOG_LEVEL=info
130
+ LOG_DIR=logs
131
+ LOG_ENABLE_CONSOLE=true
132
+ LOG_ENABLE_FILE=true
133
+ ```
134
+
135
+ 运行后会在日志目录按天生成文件:
136
+
137
+ - `<date>.log`:通用应用日志
138
+ - `<date>.access.log`:请求访问日志(JSON 行格式)
139
+ - `<date>.error.log`:错误级别日志
140
+
141
+ 请求日志中间件会自动处理 `x-request-id`:
142
+
143
+ - 如果请求头带有 `x-request-id`,服务端会透传并写入日志
144
+ - 如果没有,服务端会自动生成并在响应头返回
145
+ - 出错日志会带上 `requestId`,便于串联排查
146
+
147
+ ## 参数校验
148
+
149
+ 使用 Joi 进行请求参数校验,通过 `app/lib/validator.js` 的 `validate(schemas)` 生成中间件。
150
+
151
+ ### 使用方式
152
+
153
+ 在路由中挂载校验中间件,按需校验 `body`、`query`、`params`:
154
+
155
+ ```javascript
156
+ const { validate } = require('./lib/validator');
157
+ const userSchema = require('./schema/user');
158
+
159
+ // 只校验路径参数
160
+ router.get('/api/user/:id', validate({ params: userSchema.idParam }), userController.detail);
161
+
162
+ // 只校验请求体
163
+ router.post('/api/user', validate({ body: userSchema.createUserBody }), userController.create);
164
+
165
+ // 同时校验 params + body
166
+ router.put('/api/user/:id', validate({
167
+ params: userSchema.idParam,
168
+ body: userSchema.updateUserBody
169
+ }), userController.update);
170
+ ```
171
+
172
+ 校验通过后,结果在 **`ctx.state.validated`** 中:
173
+
174
+ - `ctx.state.validated.body`:校验后的 body
175
+ - `ctx.state.validated.query`:校验后的 query
176
+ - `ctx.state.validated.params`:校验后的 params
177
+
178
+ 控制器中应优先使用 `ctx.state.validated`,未走校验的路由可继续使用 `ctx.request.body` / `ctx.params`。
179
+
180
+ ### 校验失败响应
181
+
182
+ 校验未通过时返回 **422**,`message` 为**第一条**未通过项的提示,`errors` 为全部校验项:
183
+
184
+ ```json
185
+ {
186
+ "success": false,
187
+ "message": "用户名为必填",
188
+ "errors": [
189
+ { "field": "name", "message": "用户名为必填" }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ ### 添加新的校验规则
195
+
196
+ 在 `app/schema/` 下新增或修改 schema 文件,使用 Joi 定义规则(支持 `.messages()` 自定义提示),在路由中通过 `validate({ body: xxx })` 等引用即可。详见 `app/schema/user.js`。
197
+
112
198
  ## API 示例
113
199
 
114
200
  ### 获取用户列表
@@ -182,11 +268,18 @@ router.get('/api/product', productController.list);
182
268
 
183
269
  在 `app/middleware/` 目录下创建中间件文件,然后在 `app/middleware/index.js` 中引入使用。
184
270
 
271
+ ### 添加参数校验
272
+
273
+ 1. 在 `app/schema/` 下定义 Joi 规则(可参考 `app/schema/user.js`)
274
+ 2. 在 `app/router.js` 中为对应路由添加 `validate({ body, query, params })` 中间件
275
+ 3. 在控制器中从 `ctx.state.validated` 读取已校验数据
276
+
185
277
  ## 技术栈
186
278
 
187
279
  - **Koa3**: Web 框架
188
280
  - **@koa/router**: 路由
189
281
  - **koa-bodyparser**: 请求体解析
282
+ - **joi**: 参数校验(body/query/params)
190
283
  - **koa-static**: 静态资源服务
191
284
  - **koa-views**: 模板引擎支持
192
285
  - **dotenv**: 环境变量管理
@@ -1,100 +1,100 @@
1
- const userService = require('../service/user');
2
-
3
- function logMeta(ctx, extra = {}) {
4
- return {
5
- requestId: ctx.state && ctx.state.requestId,
6
- method: ctx.method,
7
- url: ctx.originalUrl || ctx.url,
8
- ...extra
9
- };
10
- }
11
-
12
- class UserController {
13
- async list(ctx) {
14
- try {
15
- const users = await userService.getUserList();
16
- ctx.logger.info('User list fetched', logMeta(ctx, { count: Array.isArray(users) ? users.length : undefined }));
17
- ctx.body = users;
18
- } catch (error) {
19
- ctx.logger.error('Failed to fetch user list', logMeta(ctx, { message: error.message, stack: error.stack }));
20
- ctx.throw(500, error.message);
21
- }
22
- }
23
-
24
- async detail(ctx) {
25
- const { id } = ctx.params;
26
-
27
- try {
28
- const user = await userService.getUserById(id);
29
- if (!user) {
30
- ctx.logger.warn('User detail not found', logMeta(ctx, { userId: id }));
31
- ctx.status = 404;
32
- ctx.body = { message: 'User not found' };
33
- return;
34
- }
35
-
36
- ctx.logger.info('User detail fetched', logMeta(ctx, { userId: id }));
37
- ctx.body = user;
38
- } catch (error) {
39
- ctx.logger.error('Failed to fetch user detail', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
40
- ctx.throw(500, error.message);
41
- }
42
- }
43
-
44
- async create(ctx) {
45
- const userData = ctx.request.body;
46
-
47
- try {
48
- const user = await userService.createUser(userData);
49
- ctx.logger.info('User created', logMeta(ctx, { userId: user && user.id }));
50
- ctx.status = 201;
51
- ctx.body = user;
52
- } catch (error) {
53
- ctx.logger.error('Failed to create user', logMeta(ctx, { message: error.message, stack: error.stack }));
54
- ctx.throw(500, error.message);
55
- }
56
- }
57
-
58
- async update(ctx) {
59
- const { id } = ctx.params;
60
- const userData = ctx.request.body;
61
-
62
- try {
63
- const user = await userService.updateUser(id, userData);
64
- if (!user) {
65
- ctx.logger.warn('User update target not found', logMeta(ctx, { userId: id }));
66
- ctx.status = 404;
67
- ctx.body = { message: 'User not found' };
68
- return;
69
- }
70
-
71
- ctx.logger.info('User updated', logMeta(ctx, { userId: id }));
72
- ctx.body = user;
73
- } catch (error) {
74
- ctx.logger.error('Failed to update user', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
75
- ctx.throw(500, error.message);
76
- }
77
- }
78
-
79
- async delete(ctx) {
80
- const { id } = ctx.params;
81
-
82
- try {
83
- const result = await userService.deleteUser(id);
84
- if (!result) {
85
- ctx.logger.warn('User delete target not found', logMeta(ctx, { userId: id }));
86
- ctx.status = 404;
87
- ctx.body = { message: 'User not found' };
88
- return;
89
- }
90
-
91
- ctx.logger.info('User deleted', logMeta(ctx, { userId: id }));
92
- ctx.status = 204;
93
- } catch (error) {
94
- ctx.logger.error('Failed to delete user', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
95
- ctx.throw(500, error.message);
96
- }
97
- }
98
- }
99
-
1
+ const userService = require('../service/user');
2
+
3
+ function logMeta(ctx, extra = {}) {
4
+ return {
5
+ requestId: ctx.state && ctx.state.requestId,
6
+ method: ctx.method,
7
+ url: ctx.originalUrl || ctx.url,
8
+ ...extra
9
+ };
10
+ }
11
+
12
+ class UserController {
13
+ async list(ctx) {
14
+ try {
15
+ const users = await userService.getUserList();
16
+ ctx.logger.info('User list fetched', logMeta(ctx, { count: Array.isArray(users) ? users.length : undefined }));
17
+ ctx.body = users;
18
+ } catch (error) {
19
+ ctx.logger.error('Failed to fetch user list', logMeta(ctx, { message: error.message, stack: error.stack }));
20
+ ctx.throw(500, error.message);
21
+ }
22
+ }
23
+
24
+ async detail(ctx) {
25
+ const { id } = ctx.state.validated ? ctx.state.validated.params : ctx.params;
26
+
27
+ try {
28
+ const user = await userService.getUserById(id);
29
+ if (!user) {
30
+ ctx.logger.warn('User detail not found', logMeta(ctx, { userId: id }));
31
+ ctx.status = 404;
32
+ ctx.body = { message: 'User not found' };
33
+ return;
34
+ }
35
+
36
+ ctx.logger.info('User detail fetched', logMeta(ctx, { userId: id }));
37
+ ctx.body = user;
38
+ } catch (error) {
39
+ ctx.logger.error('Failed to fetch user detail', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
40
+ ctx.throw(500, error.message);
41
+ }
42
+ }
43
+
44
+ async create(ctx) {
45
+ const userData = ctx.state.validated ? ctx.state.validated.body : ctx.request.body;
46
+
47
+ try {
48
+ const user = await userService.createUser(userData);
49
+ ctx.logger.info('User created', logMeta(ctx, { userId: user && user.id }));
50
+ ctx.status = 201;
51
+ ctx.body = user;
52
+ } catch (error) {
53
+ ctx.logger.error('Failed to create user', logMeta(ctx, { message: error.message, stack: error.stack }));
54
+ ctx.throw(500, error.message);
55
+ }
56
+ }
57
+
58
+ async update(ctx) {
59
+ const { id } = ctx.state.validated ? ctx.state.validated.params : ctx.params;
60
+ const userData = ctx.state.validated ? ctx.state.validated.body : ctx.request.body;
61
+
62
+ try {
63
+ const user = await userService.updateUser(id, userData);
64
+ if (!user) {
65
+ ctx.logger.warn('User update target not found', logMeta(ctx, { userId: id }));
66
+ ctx.status = 404;
67
+ ctx.body = { message: 'User not found' };
68
+ return;
69
+ }
70
+
71
+ ctx.logger.info('User updated', logMeta(ctx, { userId: id }));
72
+ ctx.body = user;
73
+ } catch (error) {
74
+ ctx.logger.error('Failed to update user', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
75
+ ctx.throw(500, error.message);
76
+ }
77
+ }
78
+
79
+ async delete(ctx) {
80
+ const { id } = ctx.state.validated ? ctx.state.validated.params : ctx.params;
81
+
82
+ try {
83
+ const result = await userService.deleteUser(id);
84
+ if (!result) {
85
+ ctx.logger.warn('User delete target not found', logMeta(ctx, { userId: id }));
86
+ ctx.status = 404;
87
+ ctx.body = { message: 'User not found' };
88
+ return;
89
+ }
90
+
91
+ ctx.logger.info('User deleted', logMeta(ctx, { userId: id }));
92
+ ctx.status = 204;
93
+ } catch (error) {
94
+ ctx.logger.error('Failed to delete user', logMeta(ctx, { userId: id, message: error.message, stack: error.stack }));
95
+ ctx.throw(500, error.message);
96
+ }
97
+ }
98
+ }
99
+
100
100
  module.exports = new UserController();
package/app/lib/logger.js CHANGED
@@ -1,141 +1,141 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const util = require('util');
4
-
5
- const LEVEL_WEIGHT = {
6
- debug: 10,
7
- info: 20,
8
- warn: 30,
9
- error: 40
10
- };
11
-
12
- function normalizeLevel(level) {
13
- const resolved = String(level || 'info').toLowerCase();
14
- return LEVEL_WEIGHT[resolved] ? resolved : 'info';
15
- }
16
-
17
- function formatDate(date = new Date()) {
18
- const pad = (n) => String(n).padStart(2, '0');
19
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
20
- }
21
-
22
- function formatTimestamp(date = new Date()) {
23
- return date.toLocaleString();
24
- }
25
-
26
- function safeSerialize(meta) {
27
- if (meta === undefined) {
28
- return '';
29
- }
30
-
31
- if (meta instanceof Error) {
32
- return JSON.stringify({
33
- name: meta.name,
34
- message: meta.message,
35
- stack: meta.stack
36
- });
37
- }
38
-
39
- if (typeof meta === 'string') {
40
- return meta;
41
- }
42
-
43
- try {
44
- return JSON.stringify(meta);
45
- } catch (error) {
46
- return util.inspect(meta, { depth: 4, breakLength: 120 });
47
- }
48
- }
49
-
50
- class Logger {
51
- constructor(options = {}) {
52
- this.level = normalizeLevel(options.level);
53
- this.enableConsole = options.enableConsole !== false;
54
- this.enableFile = options.enableFile !== false;
55
- this.dir = options.dir || 'logs';
56
- this.appName = options.appName || 'koa3-cli';
57
- this.cwd = options.cwd || process.cwd();
58
- this.logDir = path.isAbsolute(this.dir) ? this.dir : path.join(this.cwd, this.dir);
59
-
60
- if (this.enableFile) {
61
- fs.mkdirSync(this.logDir, { recursive: true });
62
- }
63
- }
64
-
65
- shouldLog(level) {
66
- return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[this.level];
67
- }
68
-
69
- write(level, message, meta) {
70
- if (!this.shouldLog(level)) {
71
- return;
72
- }
73
-
74
- const timestamp = formatTimestamp();
75
- const metaText = safeSerialize(meta);
76
- const line = `[${timestamp}] [${this.appName}] [${level.toUpperCase()}] ${message}${metaText ? ` ${metaText}` : ''}`;
77
-
78
- if (this.enableConsole) {
79
- const printer = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
80
- printer(line);
81
- }
82
-
83
- if (!this.enableFile) {
84
- return;
85
- }
86
-
87
- const date = formatDate();
88
- const commonPath = path.join(this.logDir, `${date}.log`);
89
- fs.appendFile(commonPath, `${line}\n`, () => {});
90
-
91
- if (level === 'error') {
92
- const errorPath = path.join(this.logDir, `${date}.error.log`);
93
- fs.appendFile(errorPath, `${line}\n`, () => {});
94
- }
95
- }
96
-
97
- access(data) {
98
- const line = {
99
- timestamp: formatTimestamp(),
100
- type: 'access',
101
- app: this.appName,
102
- ...data
103
- };
104
-
105
- if (this.enableConsole && this.shouldLog('info')) {
106
- console.log(`[${line.timestamp}] [${this.appName}] [ACCESS] ${line.method} ${line.url} ${line.status} ${line.duration}ms ${line.requestId}`);
107
- }
108
-
109
- if (!this.enableFile) {
110
- return;
111
- }
112
-
113
- const date = formatDate();
114
- const accessPath = path.join(this.logDir, `${date}.access.log`);
115
- fs.appendFile(accessPath, `${JSON.stringify(line)}\n`, () => {});
116
- }
117
-
118
- debug(message, meta) {
119
- this.write('debug', message, meta);
120
- }
121
-
122
- info(message, meta) {
123
- this.write('info', message, meta);
124
- }
125
-
126
- warn(message, meta) {
127
- this.write('warn', message, meta);
128
- }
129
-
130
- error(message, meta) {
131
- this.write('error', message, meta);
132
- }
133
- }
134
-
135
- function createLogger(options) {
136
- return new Logger(options);
137
- }
138
-
139
- module.exports = {
140
- createLogger
141
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const util = require('util');
4
+
5
+ const LEVEL_WEIGHT = {
6
+ debug: 10,
7
+ info: 20,
8
+ warn: 30,
9
+ error: 40
10
+ };
11
+
12
+ function normalizeLevel(level) {
13
+ const resolved = String(level || 'info').toLowerCase();
14
+ return LEVEL_WEIGHT[resolved] ? resolved : 'info';
15
+ }
16
+
17
+ function formatDate(date = new Date()) {
18
+ const pad = (n) => String(n).padStart(2, '0');
19
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
20
+ }
21
+
22
+ function formatTimestamp(date = new Date()) {
23
+ return date.toLocaleString();
24
+ }
25
+
26
+ function safeSerialize(meta) {
27
+ if (meta === undefined) {
28
+ return '';
29
+ }
30
+
31
+ if (meta instanceof Error) {
32
+ return JSON.stringify({
33
+ name: meta.name,
34
+ message: meta.message,
35
+ stack: meta.stack
36
+ });
37
+ }
38
+
39
+ if (typeof meta === 'string') {
40
+ return meta;
41
+ }
42
+
43
+ try {
44
+ return JSON.stringify(meta);
45
+ } catch (error) {
46
+ return util.inspect(meta, { depth: 4, breakLength: 120 });
47
+ }
48
+ }
49
+
50
+ class Logger {
51
+ constructor(options = {}) {
52
+ this.level = normalizeLevel(options.level);
53
+ this.enableConsole = options.enableConsole !== false;
54
+ this.enableFile = options.enableFile !== false;
55
+ this.dir = options.dir || 'logs';
56
+ this.appName = options.appName || 'koa3-cli';
57
+ this.cwd = options.cwd || process.cwd();
58
+ this.logDir = path.isAbsolute(this.dir) ? this.dir : path.join(this.cwd, this.dir);
59
+
60
+ if (this.enableFile) {
61
+ fs.mkdirSync(this.logDir, { recursive: true });
62
+ }
63
+ }
64
+
65
+ shouldLog(level) {
66
+ return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[this.level];
67
+ }
68
+
69
+ write(level, message, meta) {
70
+ if (!this.shouldLog(level)) {
71
+ return;
72
+ }
73
+
74
+ const timestamp = formatTimestamp();
75
+ const metaText = safeSerialize(meta);
76
+ const line = `[${timestamp}] [${this.appName}] [${level.toUpperCase()}] ${message}${metaText ? ` ${metaText}` : ''}`;
77
+
78
+ if (this.enableConsole) {
79
+ const printer = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
80
+ printer(line);
81
+ }
82
+
83
+ if (!this.enableFile) {
84
+ return;
85
+ }
86
+
87
+ const date = formatDate();
88
+ const commonPath = path.join(this.logDir, `${date}.log`);
89
+ fs.appendFile(commonPath, `${line}\n`, () => {});
90
+
91
+ if (level === 'error') {
92
+ const errorPath = path.join(this.logDir, `${date}.error.log`);
93
+ fs.appendFile(errorPath, `${line}\n`, () => {});
94
+ }
95
+ }
96
+
97
+ access(data) {
98
+ const line = {
99
+ timestamp: formatTimestamp(),
100
+ type: 'access',
101
+ app: this.appName,
102
+ ...data
103
+ };
104
+
105
+ if (this.enableConsole && this.shouldLog('info')) {
106
+ console.log(`[${line.timestamp}] [${this.appName}] [ACCESS] ${line.method} ${line.url} ${line.status} ${line.duration}ms ${line.requestId}`);
107
+ }
108
+
109
+ if (!this.enableFile) {
110
+ return;
111
+ }
112
+
113
+ const date = formatDate();
114
+ const accessPath = path.join(this.logDir, `${date}.access.log`);
115
+ fs.appendFile(accessPath, `${JSON.stringify(line)}\n`, () => {});
116
+ }
117
+
118
+ debug(message, meta) {
119
+ this.write('debug', message, meta);
120
+ }
121
+
122
+ info(message, meta) {
123
+ this.write('info', message, meta);
124
+ }
125
+
126
+ warn(message, meta) {
127
+ this.write('warn', message, meta);
128
+ }
129
+
130
+ error(message, meta) {
131
+ this.write('error', message, meta);
132
+ }
133
+ }
134
+
135
+ function createLogger(options) {
136
+ return new Logger(options);
137
+ }
138
+
139
+ module.exports = {
140
+ createLogger
141
+ };
@@ -0,0 +1,49 @@
1
+ const Joi = require('joi');
2
+
3
+ /**
4
+ * 根据 schemas 生成 Koa 参数校验中间件
5
+ * @param {Object} schemas - { body?: Joi.Schema, query?: Joi.Schema, params?: Joi.Schema }
6
+ * @returns {Function} Koa middleware
7
+ *
8
+ * 校验通过后,结果会挂到 ctx.state.validated 上:
9
+ * ctx.state.validated.body / .query / .params
10
+ */
11
+ function validate(schemas = {}) {
12
+ return async function validateMiddleware(ctx, next) {
13
+ const result = {};
14
+
15
+ try {
16
+ if (schemas.body) {
17
+ const value = ctx.request.body ?? {};
18
+ result.body = await schemas.body.validateAsync(value, { stripUnknown: true });
19
+ }
20
+ if (schemas.query) {
21
+ const value = ctx.query ?? {};
22
+ result.query = await schemas.query.validateAsync(value, { stripUnknown: true });
23
+ }
24
+ if (schemas.params) {
25
+ const value = ctx.params ?? {};
26
+ result.params = await schemas.params.validateAsync(value, { stripUnknown: true });
27
+ }
28
+ } catch (err) {
29
+ if (!Joi.isError(err)) {
30
+ throw err;
31
+ }
32
+ const validationError = new Error(err.message || 'Validation Failed');
33
+ validationError.status = 422;
34
+ validationError.details = err.details.map(d => ({
35
+ field: d.path.join('.'),
36
+ message: d.message
37
+ }));
38
+ throw validationError;
39
+ }
40
+
41
+ ctx.state.validated = result;
42
+ await next();
43
+ };
44
+ }
45
+
46
+ module.exports = {
47
+ validate,
48
+ Joi
49
+ };