chanjs 2.7.8 → 2.7.11
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 +261 -363
- package/config/index.js +4 -2
- package/core/App.js +35 -0
- package/core/Container.js +56 -29
- package/core/Database.js +58 -8
- package/core/EventBus.js +88 -0
- package/core/Lang.js +56 -0
- package/core/Repository.js +34 -2
- package/core/Task.js +87 -0
- package/core/errors.js +0 -5
- package/doc/00-README.md +208 -0
- package/doc/01-/346/240/270/345/277/203/347/261/273Controller-Service-Repository.md +432 -0
- package/doc/02-/345/223/215/345/272/224/344/270/216/351/224/231/350/257/257.md +255 -0
- package/doc/03-/345/256/211/345/205/250/346/250/241/345/235/227.md +264 -0
- package/doc/04-/345/255/230/345/202/250/344/270/216/347/274/223/345/255/230.md +157 -0
- package/doc/05-/345/267/245/345/205/267/344/270/216/346/240/241/351/252/214.md +309 -0
- package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +207 -0
- package/doc/07-/344/272/213/344/273/266/347/263/273/347/273/237EventBus.md +324 -0
- package/doc/08-/345/256/232/346/227/266/344/273/273/345/212/241Task.md +262 -0
- package/doc/09-/345/233/275/351/231/205/345/214/226Lang.md +220 -0
- package/index.js +30 -1
- package/middleware/log.js +48 -31
- package/middleware/waf.js +22 -90
- package/package.json +20 -2
- package/response/code.js +0 -12
- package/response/response.js +8 -2
- package/security/checker.js +14 -7
- package/security/keywords.js +2 -3
- package/utils/logger.js +60 -91
- package/utils/pages.js +13 -12
- package/utils/signal.js +21 -2
- package/USAGE.md +0 -533
- package/doc/Cache.md +0 -333
- package/doc/Common.md +0 -638
- package/doc/Controller.md +0 -223
- package/doc/Help.md +0 -390
- package/doc/QuickStart.md +0 -116
- package/doc/Repository.md +0 -560
- package/doc/Service.md +0 -240
- package/publish.bat +0 -4
- package/todo.md +0 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# 02 · 响应与错误:success / fail / AppError / CODE
|
|
2
|
+
|
|
3
|
+
统一的响应格式和错误体系,是接口"好对接"的关键。Chanjs 提供两层:
|
|
4
|
+
|
|
5
|
+
1. **响应信封**:`success()` / `fail()`(Controller 里直接 `this.success` / `this.fail`)。
|
|
6
|
+
2. **错误对象**:`AppError` 及其子类(如 `ValidationError`、`ForbiddenError`),配合全局错误处理器,把异常自动转成标准 JSON。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 一、响应信封(所有 JSON 接口统一)
|
|
11
|
+
|
|
12
|
+
无论成功失败,前端拿到的结构都是:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{ "success": true, "code": 0, "msg": "操作成功", "data": {} }
|
|
16
|
+
{ "success": false, "code": 1008, "msg": "验证码错误", "data": null }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| 字段 | 含义 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `success` | 布尔,是否成功 |
|
|
22
|
+
| `code` | 业务码(见下文 CODE 表)|
|
|
23
|
+
| `msg` | 给前端的提示文案 |
|
|
24
|
+
| `data` | 业务数据,失败时为 `null` |
|
|
25
|
+
|
|
26
|
+
### 1.1 `success(opts)` / `this.success(opts)`
|
|
27
|
+
|
|
28
|
+
纯函数(可直接 `import { success }` 或 Controller 内 `this.success`)。
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
success(); // {success:true, code:0, msg:"操作成功", data:{}}
|
|
32
|
+
success({ data: [1,2,3] }); // 带数据
|
|
33
|
+
success({ data: row, msg: "查询成功" });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| 参数 | 类型 | 默认 | 说明 |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| `opts.data` | any | `{}` | 返回数据 |
|
|
39
|
+
| `opts.msg` | string | `"操作成功"` | 提示 |
|
|
40
|
+
|
|
41
|
+
### 1.2 `fail(opts)` / `this.fail(opts)`
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
fail("用户名已存在"); // 字符串当 msg,code 默认 1008
|
|
45
|
+
fail({ code: 1006, msg: "参数缺失" });
|
|
46
|
+
fail({ code: 1008, data: {...} }); // 也可附带 data
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| 参数 | 类型 | 默认 | 说明 |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `opts`(string) | string | — | 当 `msg`,`code=1008` |
|
|
52
|
+
| `opts.msg` | string | — | 提示 |
|
|
53
|
+
| `opts.code` | number | `1008` | 业务码 |
|
|
54
|
+
| `opts.data` | any | `{}` | 附带数据 |
|
|
55
|
+
|
|
56
|
+
> 也可以脱离 Controller 直接用裸函数:`import { success, fail } from "chanjs"`。
|
|
57
|
+
> 但**在 Controller 里优先用 `this.success/this.fail`**,因为它们已经在正确的 `this` 上下文里。
|
|
58
|
+
|
|
59
|
+
### 1.3 其它响应辅助函数
|
|
60
|
+
|
|
61
|
+
| 函数 | 签名 | 说明 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `routeNotFound` | `routeNotFound(req)` | 生成 404 路由不存在响应体:`{success:false,code:1004,msg:"请求的资源不存在",data:{path,method}}` |
|
|
64
|
+
| `serializeError` | `serializeError(err, exposeDetail=false)` | 把错误序列化成统一响应对象。AppError 提取 `code/msg`+自定义字段;原生错误生产环境脱敏(仅 `exposeDetail=true` 时附带堆栈)|
|
|
65
|
+
| `buildErrorHtml` | `buildErrorHtml(status, errorMsg, code, req)` | 生成内置兜底错误 HTML 页(无模板依赖)。4 个参数:`status`(HTTP 码)、`errorMsg`(文案)、`code`(业务码或 `HTTP_xxx`)、`req` |
|
|
66
|
+
| `respondError` | `respondError(res, req, { httpStatus=500, code=500, msg, data=null })` | 按请求类型分流:API(XHR / Accept=json)返回 JSON,浏览器页(Accept=html)渲染 HTML 错误页 |
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { routeNotFound, serializeError, buildErrorHtml, respondError } from "chanjs";
|
|
70
|
+
|
|
71
|
+
const body = routeNotFound(req); // 404 体
|
|
72
|
+
const obj = serializeError(err, false); // 错误→响应对象
|
|
73
|
+
respondError(res, req, { httpStatus: 403, code: 1003, msg: "无权限" });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
> ⚠️ 旧文档把 `buildErrorHtml(err)` / `respondError(res, err)` 写成单参数是**错误**的,实际签名见上表。
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 二、错误码 CODE 字典
|
|
81
|
+
|
|
82
|
+
`CODE`(数字→文案映射)与语义常量 `CODE_XXX` 都从 `chanjs` 导出,避免硬编码数字。
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { CODE, CODE_OK, CODE_AUTH_FAILED, getCodeMsg } from "chanjs";
|
|
86
|
+
|
|
87
|
+
CODE[0]; // "操作成功"(按业务码取提示文案)
|
|
88
|
+
CODE_OK; // 0
|
|
89
|
+
CODE_AUTH_FAILED; // 1001
|
|
90
|
+
getCodeMsg(1001); // "认证失败"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2.1 完整 CODE 表(`response/code.js` 权威)
|
|
94
|
+
|
|
95
|
+
| 业务码 | 常量 | 文案 | HTTP 状态(对应错误类)|
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| `0` | `CODE_OK` | 操作成功 | 200 |
|
|
98
|
+
| `1001` | `CODE_AUTH_FAILED` | 认证失败 | 401 |
|
|
99
|
+
| `1002` | `CODE_TOKEN_EXPIRED` | 令牌已过期 | 401 |
|
|
100
|
+
| `1003` | `CODE_FORBIDDEN` | 权限不足 | 403 |
|
|
101
|
+
| `1004` | `CODE_NOT_FOUND` | 资源不存在 | 404 |
|
|
102
|
+
| `1005` | `CODE_CONFLICT` | 资源已存在 | 409 |
|
|
103
|
+
| `1006` | `CODE_PARAM_INVALID` | 参数无效 | 422 |
|
|
104
|
+
| `1007` | `CODE_PARAM_MISSING` | 参数缺失 | 400 |
|
|
105
|
+
| `1008` | `CODE_BUSINESS_FAIL` | 业务处理失败 | 400 |
|
|
106
|
+
| `1009` | `CODE_RATE_LIMIT` | 请求过于频繁 | 429 |
|
|
107
|
+
| `1010` | `CODE_DEVICE_ERROR` | 登录设备异常 | 403 |
|
|
108
|
+
| `1011` | `CODE_BLOCKED` | 访问已被限制 | 403 |
|
|
109
|
+
| `5001` | `CODE_SYSTEM_ERROR` | 系统内部错误 | 500 |
|
|
110
|
+
| `5002` | `CODE_SERVICE_BUSY` | 服务繁忙,请稍后再试 | 503 |
|
|
111
|
+
| `6001` | `CODE_DB_CONNECTION_ERROR` | 数据库连接失败 | 503 |
|
|
112
|
+
| `6002` | `CODE_DB_ACCESS_DENIED` | 数据库访问被拒绝 | 503 |
|
|
113
|
+
| `6003` | — | 存在关联数据,操作失败 | 409 |
|
|
114
|
+
| `6004` | — | 数据库字段错误 | 400 |
|
|
115
|
+
| `6005` | — | 数据重复,违反唯一性约束 | 409 |
|
|
116
|
+
| `6006` | — | 目标表不存在 | 500 |
|
|
117
|
+
| `6007` | `CODE_DB_OPERATION_TIMEOUT` | 数据库操作超时 | 503 |
|
|
118
|
+
| `6008` | — | 数据库语法错误,请检查查询语句 | 500 |
|
|
119
|
+
| `6009` | — | 数据库连接已关闭,请重试 | 500 |
|
|
120
|
+
|
|
121
|
+
> 注:`CODE` 数字是完整权威来源;语义常量只覆盖了常用码(1001–1011、5001、5002、6001、6002、6007)。
|
|
122
|
+
> 未提供常量的码可直接用数字(如 `6005`)。
|
|
123
|
+
|
|
124
|
+
`getCodeMsg(code, fallback="操作失败")`:按码取默认文案,查不到返回 `fallback`。
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 三、AppError 错误族(推荐用"抛错"代替 `this.fail`)
|
|
129
|
+
|
|
130
|
+
比起在 Controller 里 `return this.fail(...)`,更优雅的是**直接 throw 一个错误对象**,
|
|
131
|
+
框架的全局错误处理器会自动把它转成标准 JSON。这样业务逻辑(Service/Repository)里也能直接抛。
|
|
132
|
+
|
|
133
|
+
### 3.1 基类 `AppError`
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { AppError } from "chanjs";
|
|
137
|
+
|
|
138
|
+
// 构造:code(业务码), msg(文案), httpStatus(HTTP状态), cause(底层错误)
|
|
139
|
+
throw new AppError(1008, "用户名已存在", 400);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| `code` | number | ✅ | 业务码 |
|
|
145
|
+
| `msg` | string | ✅ | 提示 |
|
|
146
|
+
| `httpStatus` | number | ❌ | HTTP 状态,默认 400 |
|
|
147
|
+
| `cause` | Error | ❌ | 底层原始错误(会被记录进日志)|
|
|
148
|
+
|
|
149
|
+
> 🛑 **顺序不能写反**:`new AppError("用户名已存在", 1008)` 是**错的**(会把 msg 当 code)。
|
|
150
|
+
> 正确是 `new AppError(1008, "用户名已存在")`。
|
|
151
|
+
|
|
152
|
+
### 3.2 预定义子类(最常用)
|
|
153
|
+
|
|
154
|
+
这些子类已把 `code` 和 `httpStatus` 绑定好,你只管传文案:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
import {
|
|
158
|
+
AuthError, TokenExpiredError, ForbiddenError, NotFoundError,
|
|
159
|
+
ConflictError, ValidationError, ParamMissingError, BusinessError,
|
|
160
|
+
RateLimitError, BlockedError, SystemError, ServiceBusyError,
|
|
161
|
+
DbConnectionError, DbAccessDeniedError, DbTimeoutError,
|
|
162
|
+
} from "chanjs";
|
|
163
|
+
|
|
164
|
+
throw new ValidationError("标题不能为空"); // code=1006, http=422
|
|
165
|
+
throw new ForbiddenError("无权限"); // code=1003, http=403
|
|
166
|
+
throw new NotFoundError("文章不存在"); // code=1004, http=404
|
|
167
|
+
throw new BusinessError("库存不足"); // code=1008, http=400
|
|
168
|
+
throw new AuthError("请先登录"); // code=1001, http=401
|
|
169
|
+
throw new RateLimitError("请求过于频繁"); // code=1009, http=429
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**带额外字段**:`ValidationError` 支持第二个参数传字段列表(用于前端高亮);`RateLimitError` / `BlockedError` 可传 `retryAfter`:
|
|
173
|
+
```js
|
|
174
|
+
throw new ValidationError("参数校验失败", ["title", "content"]);
|
|
175
|
+
throw new RateLimitError("请求过于频繁", 30); // retryAfter = 30s
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**带底层错误**:把数据库异常包进去,便于排查:
|
|
179
|
+
```js
|
|
180
|
+
try { await repo.insert(data); }
|
|
181
|
+
catch (e) { throw new SystemError("写入失败", e); }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**两种构造写法**(所有子类通用):
|
|
185
|
+
```js
|
|
186
|
+
new ValidationError("标题不能为空"); // 字符串当 msg
|
|
187
|
+
new ValidationError({ msg: "标题不能为空", fields: [...] }); // 对象写法,可带 extraProps / cause
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 3.3 辅助函数
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
import { isAppError, describeError, errorExtraProps, parseStack, wrapDbError } from "chanjs";
|
|
194
|
+
|
|
195
|
+
isAppError(err); // 判断是不是业务错误(AppError 实例)
|
|
196
|
+
describeError(err); // 递归生成可读错误链路(含 ECONNREFUSED/ENOTFOUND 等智能提示 + cause 链)
|
|
197
|
+
errorExtraProps(err); // 提取错误附加属性字符串,用于日志
|
|
198
|
+
parseStack(err.stack); // 解析出 { message, file, line },定位报错文件
|
|
199
|
+
wrapDbError(err); // 把数据库底层异常转成对应 AppError 子类
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**`wrapDbError` 很实用**:在 Repository 里 catch 数据库错误后调用,会自动映射成友好错误:
|
|
203
|
+
```js
|
|
204
|
+
import { wrapDbError } from "chanjs";
|
|
205
|
+
try { /* knex 操作 */ }
|
|
206
|
+
catch (e) { throw wrapDbError(e); }
|
|
207
|
+
// ER_DUP_ENTRY / 23505 → ConflictError("数据已存在")
|
|
208
|
+
// ER_ACCESS_DENIED_ERROR → DbAccessDeniedError("数据库访问被拒绝")
|
|
209
|
+
// ER_NO_SUCH_TABLE → SystemError("数据表不存在")
|
|
210
|
+
// ER_BAD_FIELD_ERROR → ParamMissingError("数据库字段错误")
|
|
211
|
+
// ER_ROW_IS_REFERENCED_2 → ConflictError("存在关联数据,操作失败")
|
|
212
|
+
// ECONNREFUSED/ENOTFOUND/… → DbConnectionError("数据库连接失败")
|
|
213
|
+
// 其它 → SystemError("数据操作异常")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
> `describeError` 内置递归深度限制(10 层)防栈溢出,并会展开 `AggregateError`。
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 四、完整示例:用抛错写业务
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
import { Controller } from "chanjs";
|
|
224
|
+
import { ValidationError, NotFoundError } from "chanjs";
|
|
225
|
+
import { ArticleService } from "../service/ArticleService.js";
|
|
226
|
+
|
|
227
|
+
export class ArticleController extends Controller {
|
|
228
|
+
constructor() { super(); this.service = new ArticleService(); }
|
|
229
|
+
|
|
230
|
+
async detail(req, res) {
|
|
231
|
+
const { id } = req.query;
|
|
232
|
+
if (!id) throw new ValidationError("缺少 id");
|
|
233
|
+
|
|
234
|
+
const row = await this.service.getById(id);
|
|
235
|
+
if (!row) throw new NotFoundError("文章不存在"); // 抛错即可,框架接管响应
|
|
236
|
+
|
|
237
|
+
this.success({ data: row });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
不用写 `try/catch` 返回失败——抛出 `AppError` 子类后,全局错误处理器会输出:
|
|
243
|
+
```json
|
|
244
|
+
{ "success": false, "code": 1004, "msg": "文章不存在", "data": null }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## 五、常见坑
|
|
250
|
+
|
|
251
|
+
1. **`AppError` 参数顺序写反**:`new AppError("消息", 1008)` → 实际 `code` 变成字符串,前端判断全错。永远是 `(code, msg, httpStatus)`。
|
|
252
|
+
2. **在 Service 里 `return this.fail`**:Service 没有 `this.fail`,请用 `throw new XxxError(...)`。
|
|
253
|
+
3. **裸 `throw new Error("xx")`**:会被当成系统错误(500),建议业务错误都用 `AppError` 子类。
|
|
254
|
+
4. **`code` 硬编码**:优先用 `CODE.XXX` 常量,避免数字漂移。
|
|
255
|
+
5. **`buildErrorHtml` / `respondError` 参数个数**:实际分别是 4 参和 3 参(见 1.3),旧文档单参写法是错的。
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# 03 · 安全模块:JWT / XSS 过滤 / 关键词校验 / 限流 / 加解密
|
|
2
|
+
|
|
3
|
+
做登录、表单提交、评论、上传时必看。所有函数都从 `"chanjs"` 导出。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 一、JWT 登录态(setToken / getToken / verifyToken / revokeToken)
|
|
8
|
+
|
|
9
|
+
文件:`security/jwt.js`。基于 `jsonwebtoken`,用 HS256 签名。黑名单存在 `store`(见 04 文档)。
|
|
10
|
+
|
|
11
|
+
### 1.1 `setToken(data, secretKey, expire="7d")` —— 签发令牌
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { setToken } from "chanjs";
|
|
15
|
+
|
|
16
|
+
const token = setToken(
|
|
17
|
+
{ userId: 1, role: "admin" }, // 载荷(不要放密码等敏感信息)
|
|
18
|
+
process.env.JWT_SECRET, // 签名密钥(必须配,否则返回 null)
|
|
19
|
+
"7d" // 有效期:'7d' / '2h' / 3600(秒)
|
|
20
|
+
);
|
|
21
|
+
// token 是字符串;secret 缺失返回 null
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| `data` | object | ✅ | 载荷(用户标识等)|
|
|
27
|
+
| `secretKey` | string | ✅ | 签名密钥,通常取 `process.env.JWT_SECRET` |
|
|
28
|
+
| `expire` | string | ❌ | 有效期(数字按秒,字符串如 `'7d'`/`'2h'`),默认 `"7d"` |
|
|
29
|
+
|
|
30
|
+
> 登录成功后用 `res.cookie("token", token, {...})` 下发给浏览器(见 ChanCMS 的 `Member.login` / `SysUser.login`)。
|
|
31
|
+
> 会员端用的是 `ut` cookie(httpOnly),后台用 `token` cookie。
|
|
32
|
+
|
|
33
|
+
### 1.2 `verifyToken(token, secretKey)` —— 校验(异步)
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { verifyToken } from "chanjs";
|
|
37
|
+
|
|
38
|
+
const { valid, reason, payload } = await verifyToken(token, process.env.JWT_SECRET);
|
|
39
|
+
if (!valid) {
|
|
40
|
+
// reason: "missing" | "expired" | "invalid" | "revoked"
|
|
41
|
+
throw new AuthError("登录失效");
|
|
42
|
+
}
|
|
43
|
+
console.log(payload.userId);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
返回对象:
|
|
47
|
+
|
|
48
|
+
| 字段 | 类型 | 说明 |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `valid` | boolean | 是否合法 |
|
|
51
|
+
| `reason` | string | 失败原因:`"missing"`(无 token)/ `"expired"`(过期)/ `"invalid"`(签名无效/伪造)/ `"revoked"`(已注销)|
|
|
52
|
+
| `payload` | object | 合法时的载荷 |
|
|
53
|
+
| `error` | Error | 失败时的原始错误(仅验签阶段)|
|
|
54
|
+
|
|
55
|
+
> 🔒 流程:先验签(无效/过期直接拒)→ 再查黑名单(已注销则拒)。
|
|
56
|
+
|
|
57
|
+
### 1.3 `getToken(token, secretKey)` —— 取载荷(校验成功才返回)
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const payload = await getToken(token, process.env.JWT_SECRET);
|
|
61
|
+
// 合法 → payload;非法 → null
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 1.4 `revokeToken(token, ttl)` —— 注销(加入黑名单)
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { revokeToken } from "chanjs";
|
|
68
|
+
|
|
69
|
+
// 改密码/登出时调用:把旧 token 写入黑名单,ttl = 旧 token 剩余毫秒数
|
|
70
|
+
const decoded = await getToken(oldToken, secret);
|
|
71
|
+
const ttl = decoded.exp * 1000 - Date.now(); // 剩余有效期(ms)
|
|
72
|
+
await revokeToken(oldToken, ttl);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| `token` | string | ✅ | 要注销的令牌 |
|
|
78
|
+
| `ttl` | number | ✅ | 黑名单存活毫秒数(一般传 token 剩余有效期);`<=0` 视为无需注销,直接返回 `true` |
|
|
79
|
+
|
|
80
|
+
> ⚠️ `revokeToken` 当前已在 `chanjs/index.js` 中显式导出(`import { revokeToken } from "chanjs"` 可直接用)。
|
|
81
|
+
> 新增 import 前仍建议核对 `index.js` 导出列表,避免漏导出导致整模块加载失败。
|
|
82
|
+
|
|
83
|
+
### 1.5 黑名单存储
|
|
84
|
+
|
|
85
|
+
`revokeToken` / `verifyToken` 的黑名单存在 `store`(见 04 文档)。Redis 开启时跨进程生效;
|
|
86
|
+
未开启时降级为内存(单机有效)。可用环境变量 `JWT_REVOCATION_FAIL_CLOSE=true` 切换为
|
|
87
|
+
"存储异常即拦截"(fail-close,适合金融/权限敏感场景)。
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 二、XSS 过滤 `filterXSS(data)`
|
|
92
|
+
|
|
93
|
+
文件:`security/xss-filter.js`。基于 `xss` 库,对用户输入做净化,**递归支持对象/数组/字符串**。
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { filterXSS } from "chanjs";
|
|
97
|
+
|
|
98
|
+
const clean = filterXSS(userHtml); // 字符串:去掉 <script>、onerror= 等危险内容
|
|
99
|
+
const cleanObj = filterXSS({ title, body }); // 对象/数组:逐字段递归净化
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| 参数 | 类型 | 说明 |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `data` | string \| object \| Array | 待净化数据;字符串直接净化,对象/数组递归处理(循环引用安全)|
|
|
105
|
+
| **返回** | 同输入类型 | 净化后的数据;`number/boolean/null/undefined` 原值返回 |
|
|
106
|
+
|
|
107
|
+
**使用场景**:评论、留言、富文本字段入库前净化;或渲染前净化。
|
|
108
|
+
ChanCMS 在 `BookChapter` 保存章节内容时复用它(**复用优先,不要自己重写**)。
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 三、关键词 / 安全校验 `checkKeywords(text, options?)`
|
|
113
|
+
|
|
114
|
+
文件:`security/checker.js`。检查文本是否命中敏感词 / 攻击特征(SQL 注入、命令注入、XSS、路径穿越等)。
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { checkKeywords } from "chanjs";
|
|
118
|
+
|
|
119
|
+
const hit = checkKeywords(commentText);
|
|
120
|
+
if (hit) {
|
|
121
|
+
// hit = { category: "xss", keyword: "<script" }
|
|
122
|
+
throw new BusinessError(`内容包含违规词:${hit.keyword}`);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| `text` | string | ✅ | 待检测文本(空白直接返回 `null`)|
|
|
129
|
+
| `options.scope` | `"body"` | ❌ | 传 `"body"` 时跳过「路径/文件/英文词」等易误杀的分类(wholeWord/extensions/directories/sensitiveIdentifiers/encoding),只对语义敏感词做匹配;不传则全量扫描 |
|
|
130
|
+
|
|
131
|
+
**返回**:命中时 `{ category: string, keyword: string }`;未命中或空白时 `null`。
|
|
132
|
+
|
|
133
|
+
> ⚠️ **返回结构不是 `{ blocked, reason, keyword }`**(旧文档有误)。命中对象含 `category`(分类,如 `sqlInjection`/`xss`/`commandInjection`)和 `keyword`(命中的具体词)。
|
|
134
|
+
> 判断"是否命中"用 `if (hit)` 即可。
|
|
135
|
+
|
|
136
|
+
**`scope:"body"` 为什么重要(真实踩坑):**
|
|
137
|
+
评论、留言这类"正文"里常含正常链接(如 `https://example.com/a.html`),若按全量规则扫描会误判为攻击。
|
|
138
|
+
传 `scope: "body"` 可只匹配语义敏感词,放行正常链接。
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
// 正文内容检测(推荐)
|
|
142
|
+
checkKeywords(content, { scope: "body" });
|
|
143
|
+
|
|
144
|
+
// URL / 路径检测(默认全量)
|
|
145
|
+
checkKeywords(someUrl);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 3.1 路径忽略校验 `isIgnored(path, ignorePaths)`
|
|
149
|
+
|
|
150
|
+
配套工具:判断请求路径是否命中忽略列表(大小写不敏感、去尾斜杠,支持精确/前缀匹配)。
|
|
151
|
+
> 注意:`isIgnored` 未挂到包根导出,需用深导入:`import { isIgnored } from "chanjs/security/checker.js"`。
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
import { isIgnored } from "chanjs/security/checker.js";
|
|
155
|
+
|
|
156
|
+
isIgnored("/api/health", ["/api/health", "/public"]); // true
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| 参数 | 类型 | 说明 |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `path` | string | 当前请求路径 |
|
|
162
|
+
| `ignorePaths` | string[] | 忽略路径列表 |
|
|
163
|
+
| **返回** | boolean | 命中返回 `true` |
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 四、接口限流 `createRateLimitMiddleware(opts)`
|
|
168
|
+
|
|
169
|
+
文件:`security/rate-limit.js`。防止爆破 / 刷接口。**按 IP + UID 双维度限流**(自动从 `req` 取客户端 IP 与 `req.user?.uid`)。
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
import { createRateLimitMiddleware } from "chanjs";
|
|
173
|
+
|
|
174
|
+
const emailLimiter = createRateLimitMiddleware({
|
|
175
|
+
windowMs: 5 * 60 * 1000, // 时间窗口:5 分钟(支持数字毫秒 或 "1s"/"1m"/"1h"/"1d" 字符串)
|
|
176
|
+
max: 10, // 窗口内最多 10 次(默认 60)
|
|
177
|
+
ignorePaths: ["/api/health"], // 这些路径跳过限流(默认 [])
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
router.post("/sendEmail", emailLimiter, ctrl.sendEmail.bind(ctrl));
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
|
184
|
+
|---|---|---|---|---|
|
|
185
|
+
| `windowMs` | number \| string | ❌ | `60000` | 时间窗口:数字按毫秒;字符串形如 `"1m"`(s/m/h/d 单位)|
|
|
186
|
+
| `max` | number | ❌ | `60` | 窗口内最大请求数(IP 维度和 UID 维度各自独立计数)|
|
|
187
|
+
| `ignorePaths` | string[] | ❌ | `[]` | 命中即跳过限流的路径前缀列表 |
|
|
188
|
+
|
|
189
|
+
> ⚠️ **旧文档的 `keyPrefix` / `limitKey` 选项不存在**——当前实现固定为「IP + 登录用户 UID」双维度,无法自定义维度函数。
|
|
190
|
+
> 超限时中间件直接返回 **`code=1009`(`CODE_RATE_LIMIT`,HTTP 429)**,无需你手写。
|
|
191
|
+
> 存储异常时 fail-close 直接拦截(同样返回 429)。
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 五、对称加解密 `aesEncrypt / aesDecrypt`(⚠️ 异步)
|
|
196
|
+
|
|
197
|
+
文件:`security/sign.js`。基于 AES-256-GCM(内置 scrypt 密钥派生 + LRU 缓存),用于加密敏感字段(如配置里的密钥)。
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import { aesEncrypt, aesDecrypt } from "chanjs";
|
|
201
|
+
|
|
202
|
+
// ⚠️ 这两个函数是异步的,必须 await!
|
|
203
|
+
const cipher = await aesEncrypt(plainText, secretKey); // 输出 iv+tag+密文 合并 base64
|
|
204
|
+
const plain = await aesDecrypt(cipher, secretKey);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
208
|
+
|---|---|---|---|
|
|
209
|
+
| `data` | string \| object | ✅ | 明文(对象会被 JSON 字符串化)/ 密文 |
|
|
210
|
+
| `secretKey` | string | ✅ | 派生密钥的口令;建议统一配置环境变量 `AES_SALT` 固定盐值(未配置会有一次告警并用 secret 派生)|
|
|
211
|
+
|
|
212
|
+
> 🛑 **大坑**:`aesEncrypt` / `aesDecrypt` 是 **`async` 函数**,调用必须 `await`。
|
|
213
|
+
> 漏写 `await` 会得到 `Promise` 而非字符串,后续比较/存储会全部错位。
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 六、安全模块速查表
|
|
218
|
+
|
|
219
|
+
| 函数 | 同步/异步 | 典型用途 | 注意 |
|
|
220
|
+
|---|---|---|---|
|
|
221
|
+
| `setToken` | 同步 | 登录签发 | secret 缺失返回 null |
|
|
222
|
+
| `getToken` | 异步 | 取载荷 | 非法返回 null |
|
|
223
|
+
| `verifyToken` | 异步 | 校验 | 先验签再查黑名单;reason 含 `revoked` |
|
|
224
|
+
| `revokeToken` | 异步 | 登出/改密注销 | 需传剩余 ttl(ms) |
|
|
225
|
+
| `filterXSS` | 同步 | 富文本净化 | 支持对象/数组递归;复用,别重写 |
|
|
226
|
+
| `checkKeywords` | 同步 | 敏感词 | 命中返回 `{category,keyword}`,未命中 `null`;正文用 `scope:"body"` |
|
|
227
|
+
| `isIgnored` | 同步 | 路径跳过判断 | 配合限流/扫描忽略列表 |
|
|
228
|
+
| `createRateLimitMiddleware` | 同步(返中间件) | 限流 | 选项 `{windowMs,max,ignorePaths}`;超限 `code=1009` |
|
|
229
|
+
| `aesEncrypt` / `aesDecrypt` | **异步** | 字段加密 | 必须 `await` |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 七、综合示例:登录 + 限流 + 注销
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
import { Controller } from "chanjs";
|
|
237
|
+
import { setToken, getToken, revokeToken } from "chanjs";
|
|
238
|
+
import { createRateLimitMiddleware } from "chanjs";
|
|
239
|
+
import { AuthError } from "chanjs";
|
|
240
|
+
|
|
241
|
+
const loginLimiter = createRateLimitMiddleware({ windowMs: 10*60*1000, max: 5 });
|
|
242
|
+
|
|
243
|
+
export class UserController extends Controller {
|
|
244
|
+
async login(req, res) {
|
|
245
|
+
const { username, password } = req.body;
|
|
246
|
+
const user = await this.service.verify(username, password); // 业务校验
|
|
247
|
+
if (!user) throw new AuthError("账号或密码错误");
|
|
248
|
+
|
|
249
|
+
const token = setToken({ userId: user.id }, process.env.JWT_SECRET, "7d");
|
|
250
|
+
res.cookie("token", token, { httpOnly: true, sameSite: "strict" });
|
|
251
|
+
this.success({ data: { token, user } });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async logout(req, res) {
|
|
255
|
+
const token = req.cookies.token;
|
|
256
|
+
const payload = await getToken(token, process.env.JWT_SECRET);
|
|
257
|
+
if (payload) {
|
|
258
|
+
const ttl = payload.exp * 1000 - Date.now();
|
|
259
|
+
await revokeToken(token, ttl); // 加入黑名单
|
|
260
|
+
}
|
|
261
|
+
this.success({ msg: "已退出" });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# 04 · 存储与缓存:store / cache
|
|
2
|
+
|
|
3
|
+
文件:`storage/index.js`,对外导出两个单例:`store`(Redis/内存适配层)和 `cache`(纯内存缓存)。
|
|
4
|
+
它们的 API 形状与 Redis 客户端保持一致,让业务侧无需关心底层是 Redis 还是内存。
|
|
5
|
+
|
|
6
|
+
> ⚠️ **`store` 与 `cache` 的 ttl 单位都是「毫秒(ms)」**,两者一致(旧文档称 cache 用秒是**错误**的)。
|
|
7
|
+
> 例:`store.set(k, v, 5*60*1000)` 与 `cache.set(k, v, 5*60*1000)` 都是 5 分钟。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 一、`store` —— 通用键值存储(令牌黑名单 / 限流计数 / 临时状态)
|
|
12
|
+
|
|
13
|
+
`store` 背后优先用 **Redis**(配置了 `REDIS_ENABLED` 时),否则降级为**进程内 Map**。
|
|
14
|
+
因此它适合:登录态黑名单、限流计数、验证码、分布式锁等"需要跨请求/跨进程共享"的数据。
|
|
15
|
+
所有方法失败时返回安全默认值(`null`/`false`/`0`),**不会抛异常上抛**。
|
|
16
|
+
|
|
17
|
+
### 1.1 方法签名
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { store } from "chanjs";
|
|
21
|
+
|
|
22
|
+
await store.init({ REDIS_ENABLED, REDIS }); // 框架启动时已自动 init,业务一般不用手动调
|
|
23
|
+
await store.set(key, value, ttl); // 写入
|
|
24
|
+
const v = await store.get(key); // 读取(不存在/异常返回 null)
|
|
25
|
+
await store.del(key); // 删除(不存在返回 false)
|
|
26
|
+
const ok = await store.exists(key); // 是否存在(bool)
|
|
27
|
+
const n = await store.incr(key); // 原子自增(新 key 返回 1,异常返回 0)
|
|
28
|
+
const n2 = await store.incrAndExpire(key, ttl); // 原子自增并设 TTL(限流专用)
|
|
29
|
+
await store.expire(key, ttl); // 为已存在 key 设过期(key 不存在返回 false)
|
|
30
|
+
const info = store.getInfo(); // 诊断信息(健康检查用)
|
|
31
|
+
await store.close(); // 关闭并释放资源(优雅停机自动调)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| 方法 | 签名 | 返回 | 说明 |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| `init` | `init({REDIS_ENABLED, REDIS})` | Promise | 框架启动自动调用;业务无需手动 |
|
|
37
|
+
| `set` | `set(key, value, ttl=60000)` | Promise\<boolean\> | `ttl` 单位**毫秒**;`ttl<=0` 视 Redis 为永久 |
|
|
38
|
+
| `get` | `get(key)` | Promise\<any\> | 未命中/异常返回 `null`(JSON 自动反序列化)|
|
|
39
|
+
| `del` | `del(key)` | Promise\<boolean\> | 删除;不存在/异常返回 `false` |
|
|
40
|
+
| `exists` | `exists(key)` | Promise\<boolean\> | 是否存在 |
|
|
41
|
+
| `incr` | `incr(key)` | Promise\<number\> | 原子自增;首次返回 1,异常返回 0 |
|
|
42
|
+
| `incrAndExpire` | `incrAndExpire(key, ttlMs)` | Promise\<number\> | 原子自增并设过期(限流场景专用),返回计数 |
|
|
43
|
+
| `expire` | `expire(key, ttlMs)` | Promise\<boolean\> | 给已存在 key 设过期;不存在返回 `false` |
|
|
44
|
+
| `getInfo` | `getInfo()` | `{mode, memorySize, redisConnected, circuitOpen}` | 诊断:当前后端模式/内存条目数/Redis 状态/是否熔断 |
|
|
45
|
+
| `close` | `close()` | Promise | 关闭 Redis、清空内存;优雅停机自动调 |
|
|
46
|
+
|
|
47
|
+
> ⚠️ **没有 `ttl(key)` 方法**(旧文档列出的是错误的)。想看剩余有效期可用 `getInfo()` 做健康诊断,
|
|
48
|
+
> 或直接用 `expire()` 续期。
|
|
49
|
+
|
|
50
|
+
### 1.2 实战:邮箱验证码
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import { store } from "chanjs";
|
|
54
|
+
|
|
55
|
+
// 发送验证码
|
|
56
|
+
await store.set(`email:code:${email}`, code, 5 * 60 * 1000); // 5 分钟有效(ms)
|
|
57
|
+
|
|
58
|
+
// 校验验证码
|
|
59
|
+
const saved = await store.get(`email:code:${email}`);
|
|
60
|
+
if (!saved || saved !== inputCode) throw new BusinessError("验证码错误");
|
|
61
|
+
await store.del(`email:code:${email}`);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 1.3 实战:JWT 黑名单(框架内部已用)
|
|
65
|
+
|
|
66
|
+
`security/jwt.js` 的 `revokeToken` 就是 `store.set("jwt:revoked:"+token, 1, ttl)`。
|
|
67
|
+
你无需直接使用,理解其机制即可。
|
|
68
|
+
|
|
69
|
+
### 1.4 降级与容错
|
|
70
|
+
|
|
71
|
+
- Redis 未开启 → 自动用内存 Map(**单机有效,多实例不共享**)。
|
|
72
|
+
- Redis 连接失败 → 框架 `warn` 并降级内存,不阻断启动。
|
|
73
|
+
- **熔断保护**:Redis 运行时连续失败 5 次触发熔断,30s 冷却期内全部走内存;冷却后自动尝试恢复。
|
|
74
|
+
- 黑名单查询异常时:默认 fail-open(放行),可用 `JWT_REVOCATION_FAIL_CLOSE=true` 切 fail-close。
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 二、`cache` —— 进程内内存缓存(热点数据 / 配置 / 模板变量)
|
|
79
|
+
|
|
80
|
+
`cache` 是**纯进程内 LRU + 惰性 TTL 缓存**(默认容量 10w,惰性清理间隔 10s、单批最多 1000 条)。
|
|
81
|
+
适合放"读多写少、允许短暂不一致"的数据,如站点配置、导航树、模板变量。它**更快、零网络开销**,
|
|
82
|
+
但**重启即丢、不跨进程**。方法都是**同步**的。
|
|
83
|
+
|
|
84
|
+
### 2.1 方法签名
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { cache } from "chanjs";
|
|
88
|
+
|
|
89
|
+
cache.set(key, value, ttl); // ttl 单位毫秒;不传默认 5 分钟;ttl<=0 视为永久
|
|
90
|
+
const v = cache.get(key); // 命中返回原值;未命中/过期返回 null
|
|
91
|
+
cache.has(key); // 是否存在(命中同样刷新 LRU)
|
|
92
|
+
cache.del(key); // 删除,返回是否删除成功
|
|
93
|
+
cache.clear(); // 清空全部(慎用)
|
|
94
|
+
cache.size(); // 当前条目数
|
|
95
|
+
const n = cache.incr(key, ttlMs); // 原子自增(新 key 用默认 60s TTL)
|
|
96
|
+
const n2 = cache.incrAndExpire(key, ttlMs); // 自增并刷新过期(限流专用)
|
|
97
|
+
cache.expire(key, ttlMs); // 为已存在 key 刷新过期;不存在返回 false
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| 方法 | 签名 | 返回 | 说明 |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| `set` | `set(key, value, ttl=300000)` | void | `ttl` 单位**毫秒**(默认 5 分钟);`ttl<=0` 永久 |
|
|
103
|
+
| `get` | `get(key)` | any | 未命中/过期返回 `null` |
|
|
104
|
+
| `has` | `has(key)` | boolean | 是否存在(刷新 LRU)|
|
|
105
|
+
| `del` | `del(key)` | boolean | 删除成功返回 `true` |
|
|
106
|
+
| `clear` | `clear()` | void | 清空(一般只在测试或全量刷新时用)|
|
|
107
|
+
| `size` | `size()` | number | 当前缓存条目数 |
|
|
108
|
+
| `incr` | `incr(key, ttlMs=60000)` | number | 原子自增;新建 key 起始值 1 |
|
|
109
|
+
| `incrAndExpire` | `incrAndExpire(key, ttlMs)` | number | 自增并刷新过期(限流/计数)|
|
|
110
|
+
| `expire` | `expire(key, ttlMs)` | boolean | 给已存在 key 刷新过期;不存在返回 `false` |
|
|
111
|
+
|
|
112
|
+
> ⚠️ `cache` 是**同步**的,不要画蛇添足 `await`;`store` 是**异步**的,必须 `await`。
|
|
113
|
+
> `set` 的 `ttl` 同样是**毫秒**——和 `store` 单位一致(旧文档的"秒"是错误)。
|
|
114
|
+
|
|
115
|
+
### 2.2 实战:缓存站点配置 30 分钟
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
import { cache } from "chanjs";
|
|
119
|
+
|
|
120
|
+
async function getSiteConfig() {
|
|
121
|
+
const hit = cache.get("site:config");
|
|
122
|
+
if (hit) return hit;
|
|
123
|
+
|
|
124
|
+
const cfg = await siteRepo.find({ type: "site" });
|
|
125
|
+
cache.set("site:config", cfg, 30 * 60 * 1000); // 缓存 30 分钟(ms)
|
|
126
|
+
return cfg;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
> ChanCMS 的 `web/middleware/init.js` 正是用 `cache` 把 `site/nav/category` 等全局变量缓存,
|
|
131
|
+
> 避免每次请求都查库。
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 三、store vs cache 怎么选
|
|
136
|
+
|
|
137
|
+
| 维度 | `store` | `cache` |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| 后端 | Redis(可降级内存)| 纯内存 LRU |
|
|
140
|
+
| 同步性 | 异步(返回 Promise,必须 `await`)| 同步(直接返回值)|
|
|
141
|
+
| 跨进程/多实例 | ✅(Redis 时)| ❌ 仅本进程 |
|
|
142
|
+
| ttl 单位 | 毫秒 | 毫秒(一致)|
|
|
143
|
+
| 典型用途 | 验证码、令牌黑名单、限流、分布式锁 | 配置、导航树、模板变量、热点列表 |
|
|
144
|
+
| 一致性要求 | 强(如注销要立即生效)| 弱(允许分钟级过期)|
|
|
145
|
+
|
|
146
|
+
**经验法则**:涉及"安全/状态正确性"用 `store`;涉及"性能/少查库"用 `cache`。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 四、常见坑
|
|
151
|
+
|
|
152
|
+
1. **ttl 单位**:`store.set` 与 `cache.set` 的 ttl **都是毫秒**。写错会让缓存瞬间失效或永不过期。
|
|
153
|
+
2. **`store` 是异步**:方法返回 Promise 必须 `await`;`cache` 是同步的,别画蛇添足 `await`。
|
|
154
|
+
3. **`store.get` 返回 `null` 当"未命中"**:判断存在要用 `exists()`,不要 `if(get)` 区分(值为 `0`/`false` 也会误判)。
|
|
155
|
+
4. **`store` 没有 `ttl(key)`**:旧文档列出的该方法不存在,请用 `getInfo()` / `expire()`。
|
|
156
|
+
5. **内存 `cache` 重启即丢**:不要往 `cache` 放必须持久的数据(如未提交的订单)。
|
|
157
|
+
6. **Redis 未开时 `store` 仅单机**:多实例部署务必开启 `REDIS_ENABLED`,否则限流/黑名单不共享。
|