chanjs 2.7.5 → 2.7.6

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.
@@ -0,0 +1,27 @@
1
+ import { getApp } from "./registry.js";
2
+
3
+ /**
4
+ * 轻量基础组件类:提供 app / config / db 快捷访问
5
+ * Controller / Service / Repository 统一继承此类,
6
+ * 不再强制绑定组件加载(Container)职责。
7
+ */
8
+ export class BaseComponent {
9
+ /** 获取全局应用实例 */
10
+ get app() { return getApp(); }
11
+
12
+ /**
13
+ * 全局配置快捷访问
14
+ * 业务 Controller/Service/Repository 大量使用 `this.config.xxx` 读取配置项
15
+ * 统一从 app.config 取,未初始化时返回空对象避免 undefined.xxx 报错
16
+ */
17
+ get config() {
18
+ return this.app?.config ?? {};
19
+ }
20
+
21
+ /** 数据库连接快捷访问(默认连接) */
22
+ get db() {
23
+ return this.app?.db ?? null;
24
+ }
25
+ }
26
+
27
+ export default BaseComponent;
package/core/Container.js CHANGED
@@ -1,44 +1,35 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { Paths } from '../utils/paths.js';
4
- import { getApp } from './registry.js';
4
+ import { BaseComponent } from './BaseComponent.js';
5
5
  import logger from "../utils/logger.js";
6
6
 
7
7
  const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
8
8
 
9
9
  /**
10
- * 组件容器:统一加载 controller/service
10
+ * 组件容器:按需动态加载 controller/service 组件(组合模式)
11
+ * 继承 BaseComponent 获得 app/config/db 访问,额外提供组件加载能力
11
12
  */
12
- export class Container {
13
+ export class Container extends BaseComponent {
13
14
  constructor(type = 'service') {
14
- this.cache = new Map();
15
- this.type = type;
16
- this.baseDir = Paths.modulesPath;
17
- }
18
-
19
- get app() { return getApp(); }
20
-
21
- /**
22
- * 全局配置快捷访问
23
- * 业务 Controller/Service/Repository 大量使用 `this.config.xxx` 读取配置项
24
- * 统一从 app.config 取,未初始化时返回空对象避免 undefined.xxx 报错
25
- */
26
- get config() {
27
- return this.app?.config ?? {};
15
+ super();
16
+ this._componentCache = new Map();
17
+ this._type = type;
18
+ this._baseDir = Paths.modulesPath;
28
19
  }
29
20
 
30
21
  /**
31
- * 获取组件,自动缓存
32
- * @param {string} moduleName
33
- * @param {string} fileName
22
+ * 按需获取组件实例,自动缓存
23
+ * @param {string} moduleName 模块名
24
+ * @param {string} fileName 组件文件名(不含 .js)
34
25
  */
35
26
  async get(moduleName, fileName) {
36
27
  const cacheKey = `${moduleName}.${fileName}`;
37
- if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
28
+ if (this._componentCache.has(cacheKey)) return this._componentCache.get(cacheKey);
38
29
 
39
30
  try {
40
- const instance = await this.loadFile(moduleName, fileName);
41
- if (!instance) logger.info(`[Container] ${moduleName}/${this.type}/${fileName}.js 导出实例为空`);
31
+ const instance = await this._loadFile(moduleName, fileName);
32
+ if (!instance) logger.info(`[Container] ${moduleName}/${this._type}/${fileName}.js 导出实例为空`);
42
33
  return instance;
43
34
  } catch (err) {
44
35
  logger.error(`[Container] 加载组件失败 ${moduleName}/${fileName}:${err.message}`);
@@ -47,15 +38,15 @@ export class Container {
47
38
  }
48
39
 
49
40
  /** 物理加载文件、导入模块、安全校验 */
50
- async loadFile(moduleName, fileName) {
41
+ async _loadFile(moduleName, fileName) {
51
42
  const cacheKey = `${moduleName}.${fileName}`;
52
43
 
53
44
  if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName)) {
54
45
  throw new Error(`非法名称 ${moduleName}/${fileName}`);
55
46
  }
56
47
 
57
- const filePath = path.resolve(this.baseDir, moduleName, this.type, `${fileName}.js`);
58
- const relativePath = path.relative(this.baseDir, filePath);
48
+ const filePath = path.resolve(this._baseDir, moduleName, this._type, `${fileName}.js`);
49
+ const relativePath = path.relative(this._baseDir, filePath);
59
50
  if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
60
51
  throw new Error(`路径越界拦截:${filePath}`);
61
52
  }
@@ -63,13 +54,13 @@ export class Container {
63
54
  try {
64
55
  await fs.promises.access(filePath, fs.constants.R_OK);
65
56
  } catch {
66
- logger.info(`[Container] 文件不存在:${moduleName}/${this.type}/${fileName}.js`);
57
+ logger.info(`[Container] 文件不存在:${moduleName}/${this._type}/${fileName}.js`);
67
58
  return null;
68
59
  }
69
60
 
70
61
  const mod = await import(`file://${filePath}`);
71
62
  const instance = mod.default;
72
- this.cache.set(cacheKey, instance);
63
+ this._componentCache.set(cacheKey, instance);
73
64
  return instance;
74
65
  }
75
66
  }
@@ -1,4 +1,4 @@
1
- import Container from "./Container.js";
1
+ import { BaseComponent } from "./BaseComponent.js";
2
2
  import logger from "../utils/logger.js";
3
3
  import {
4
4
  CODE_OK,
@@ -8,7 +8,7 @@ import {
8
8
  } from "../response/code.js";
9
9
 
10
10
  const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
11
- const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>', $in:'in' };
11
+ const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>' };
12
12
 
13
13
  /**
14
14
  * 拼接查询条件,支持操作符/等值/数组in
@@ -24,12 +24,12 @@ function applyQuery(dbQuery, query) {
24
24
  }
25
25
  if (val && typeof val === 'object' && !Array.isArray(val)) {
26
26
  const ops = Object.keys(val);
27
- if (ops.length && ops.every(k => k in OPERATORS || k === '$null' || k === '$notNull')) {
27
+ if (ops.length && ops.every(k => k in OPERATORS || k === '$in' || k === '$null' || k === '$notNull')) {
28
28
  for (const [op, operand] of Object.entries(val)) {
29
- if (OPERATORS[op]) {
30
- dbQuery = dbQuery.where(field, OPERATORS[op], operand);
31
- } else if (op === '$in') {
29
+ if (op === '$in') {
32
30
  dbQuery = dbQuery.whereIn(field, Array.isArray(operand) ? operand : [operand]);
31
+ } else if (OPERATORS[op]) {
32
+ dbQuery = dbQuery.where(field, OPERATORS[op], operand);
33
33
  } else if (op === '$null') {
34
34
  operand ? dbQuery.whereNull(field) : dbQuery.whereNotNull(field);
35
35
  } else if (op === '$notNull') {
@@ -71,10 +71,11 @@ function applySelectAndSort(q, fields, sort) {
71
71
 
72
72
  /**
73
73
  * 数据访问基类,封装通用CRUD操作
74
+ * 继承 BaseComponent 获得 app/config/db 快捷访问,不绑定组件加载职责
74
75
  */
75
- class Repository extends Container {
76
+ class Repository extends BaseComponent {
76
77
  constructor(table=null, dbName=null, opts={}) {
77
- super('service');
78
+ super();
78
79
  this.tableName = table;
79
80
  this._dbName = dbName;
80
81
  this._customDb = null;
@@ -82,13 +83,13 @@ class Repository extends Container {
82
83
  typeof this.on === 'function' && this.on();
83
84
  }
84
85
 
85
- /** 动态获取数据库连接 */
86
+ /** 动态获取数据库连接(支持多库:指定 dbName 时取对应连接,否则取默认) */
86
87
  get db() {
87
88
  if (this._dbName) {
88
- this._customDb ??= this.app.dbManager.get(this._dbName);
89
+ this._customDb ??= this.app?.dbManager?.get(this._dbName);
89
90
  return this._customDb;
90
91
  }
91
- return this.app.db;
92
+ return this.app?.db ?? null;
92
93
  }
93
94
 
94
95
  /** 分页配置快捷读取 */
@@ -260,16 +261,11 @@ class Repository extends Container {
260
261
  };
261
262
  }
262
263
 
263
- /** 标准分页查询(业务侧常用别名) */
264
+ /** 标准分页查询 */
264
265
  async query(params={}) {
265
266
  return this._doPaginate(params);
266
267
  }
267
268
 
268
- /** 标准分页查询(list 别名,向后兼容) */
269
- async list(params={}) {
270
- return this._doPaginate(params);
271
- }
272
-
273
269
  /** 统计符合条件记录行数 */
274
270
  async count(query={}) {
275
271
  this._checkDB();
@@ -1,4 +1,4 @@
1
- import { routeNotFound, serializeError, buildErrorHtml, respondError } from "../../response/index.js";
1
+ import { routeNotFound, serializeError, respondError } from "../../response/index.js";
2
2
  import { CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL } from "../../response/code.js";
3
3
  import {
4
4
  AppError, isAppError, parseStack, describeError, wrapDbError,
@@ -84,21 +84,18 @@ export function registerErrorHandler(chan) {
84
84
  // 双重安全校验:生产环境即使环境变量开了,也必须携带合法 Debug Token 才暴露堆栈/SQL
85
85
  const exposeDetail = EXPOSE_ERR_DETAIL && (!IS_PROD || req.headers["x-debug-token"] === process.env.DEBUG_TOKEN);
86
86
 
87
- // 统一判断并返回内置HTML页面(仅浏览器页面访问;API 请求一律 JSON)
88
- const sendHtml = (status, message, bizCode) => {
89
- if (req.xhr || req.accepts?.(["json", "html"]) === "json") return false;
90
- if (!req.accepts?.("html")) return false;
91
- res.status(status).type("html").send(buildErrorHtml(status, message, bizCode, req));
92
- return true;
93
- };
94
-
95
87
  if (isAppError(err)) {
96
- if (sendHtml(httpStatus, err.message, err.code)) return;
97
- return res.status(httpStatus).json(serializeError(err, exposeDetail));
88
+ // 统一通过 respondError HTML/JSON 分流(复用 response 层纯函数,消除重复逻辑)
89
+ const serialized = serializeError(err, exposeDetail);
90
+ return respondError(res, req, {
91
+ httpStatus,
92
+ code: serialized.code,
93
+ msg: serialized.msg,
94
+ data: serialized.data ?? null,
95
+ });
98
96
  }
99
97
 
100
- // 兜底分支(理论上 wrapDbError 已转 AppError,这里防万一)
101
- if (sendHtml(500, "服务器内部错误", 500)) return;
102
- res.status(500).json(serializeError(err, exposeDetail));
98
+ // 兆底分支(理论上 wrapDbError 已转 AppError,这里防万一)
99
+ respondError(res, req, { httpStatus: 500, code: 500, msg: "服务器内部错误" });
103
100
  });
104
101
  }
package/core/errors.js CHANGED
@@ -45,7 +45,7 @@ export class AppError extends Error {
45
45
  if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
46
46
  }
47
47
 
48
- /** 统一判断是否为业务错误实例 */
48
+ /** 统一判断是否为业务错误实例(推荐使用独立导出函数 isAppError) */
49
49
  static isAppError(err) {
50
50
  return err instanceof AppError;
51
51
  }
@@ -70,9 +70,30 @@ function createErrorClass(className) {
70
70
 
71
71
  return class extends AppError {
72
72
  constructor(msgOrOpts, ...rest) {
73
- const opts = (msgOrOpts && typeof msgOrOpts === 'object') ? msgOrOpts : { msg: msgOrOpts, ...Object.fromEntries(cfg.extraProps?.map((k, i) => [k, rest[i]]) ?? []), cause: rest[cfg.extraProps?.length] };
73
+ // 支持两种调用方式:
74
+ // new XxxError("消息")
75
+ // new XxxError({ msg: "消息", cause: err, ...extraProps })
76
+ let opts;
77
+ if (msgOrOpts && typeof msgOrOpts === 'object') {
78
+ opts = msgOrOpts;
79
+ } else {
80
+ opts = { msg: msgOrOpts };
81
+ // 将 extraProps 按顺序映射到 rest 参数
82
+ if (cfg.extraProps) {
83
+ cfg.extraProps.forEach((key, i) => {
84
+ if (rest[i] !== undefined) opts[key] = rest[i];
85
+ });
86
+ // extraProps 之后的参数视为 cause
87
+ const causeIdx = cfg.extraProps.length;
88
+ if (rest[causeIdx] !== undefined) opts.cause = rest[causeIdx];
89
+ } else if (rest[0] !== undefined) {
90
+ opts.cause = rest[0];
91
+ }
92
+ }
93
+
74
94
  super(cfg.code, opts.msg ?? cfg.defaultMsg, cfg.httpStatus, opts.cause ?? null);
75
95
  this.name = className;
96
+ // 挂载额外属性(如 fields / retryAfter)
76
97
  for (const [k, v] of Object.entries(opts)) {
77
98
  if (k !== 'msg' && k !== 'cause' && v !== undefined) this[k] = v;
78
99
  }
@@ -192,6 +213,12 @@ export function wrapDbError(err) {
192
213
 
193
214
  if (isDbConnectionError(e)) return new DbConnectionError("数据库连接失败,请稍后重试", err);
194
215
 
216
+ // 模板/视图渲染错误:Express 的 res.render 找不到模板时抛出
217
+ const errMsg = String(e?.message || "").toLowerCase();
218
+ if (errMsg.includes("failed to lookup view") || errMsg.includes("no default engine")) {
219
+ return new SystemError("模板渲染异常:" + (e?.message || "视图文件不存在"), err);
220
+ }
221
+
195
222
  const driverCode = e?.code;
196
223
  switch (driverCode) {
197
224
  case "ER_DUP_ENTRY":
package/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * await chan.start();
8
8
  *
9
9
  * 命名导出:
10
- * import { Controller, Repository, Service, helper, cache, Paths, loader, utils, getApp } from "chanjs";
10
+ * import { Controller, Repository, Service, cache, Paths, loader, utils, getApp } from "chanjs";
11
11
  * import { validate, validateAll } from "chanjs"; // zod 校验中间件
12
12
  * import { AppError, NotFoundError, ValidationError } from "chanjs"; // 错误类
13
13
  *
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  // ===================== 核心基础类 =====================
20
+ export { BaseComponent } from "./core/BaseComponent.js";
20
21
  export { default as Controller } from "./core/Controller.js";
21
22
  export { default as Repository } from "./core/Repository.js";
22
23
  export { Service } from "./core/Service.js";
@@ -55,21 +56,6 @@ export * as utils from "./utils/index.js";
55
56
  export { cache, store } from "./storage/index.js";
56
57
  export { Paths } from "./utils/paths.js";
57
58
 
58
- // ===================== helper 聚合对象 =====================
59
- // 为兼容老代码 import { helper } from "chanjs",薄封装:单一来源 = 命名导出 + 工具命名空间
60
- import * as utilsNs from "./utils/index.js";
61
- import { cache, store } from "./storage/index.js";
62
- import { success, fail } from "./response/index.js";
63
- import { setToken, getToken, aesEncrypt, aesDecrypt } from "./security/index.js";
64
- import { createRateLimitMiddleware } from "./security/rate-limit.js";
65
- import { filterXSS, checkKeywords } from "./security/index.js";
66
-
67
- export const helper = Object.assign(Object.create(null), utilsNs, {
68
- cache, store, success, fail,
69
- setToken, getToken, aesEncrypt, aesDecrypt,
70
- createRateLimitMiddleware, filterXSS, checkKeywords,
71
- });
72
-
73
59
  // ===================== 默认导出应用主类 =====================
74
60
  import Chan from "./core/App.js";
75
61
  export default Chan;
@@ -1,11 +1,19 @@
1
+ import fs from "fs";
1
2
  import path from "path";
2
3
  import serveFavicon from "serve-favicon";
3
4
  import { Paths } from "../utils/paths.js";
5
+ import logger from "../utils/logger.js";
4
6
 
5
7
  /**
6
8
  * 网站图标中间件,读取public目录favicon.ico
9
+ * 文件不存在时静默跳过,不阻断启动
7
10
  * @param {express.Application} app Express实例
8
11
  */
9
12
  export const favicon = app => {
10
- app.use(serveFavicon(path.join(Paths.publicPath, "favicon.ico")));
13
+ const icoPath = path.join(Paths.publicPath, "favicon.ico");
14
+ if (!fs.existsSync(icoPath)) {
15
+ logger.info("[Favicon] favicon.ico 不存在,跳过图标中间件");
16
+ return;
17
+ }
18
+ app.use(serveFavicon(icoPath));
11
19
  };
package/middleware/waf.js CHANGED
@@ -89,7 +89,7 @@ async function handleStrike(fingerprint, clientIp, keyword, blockCfg) {
89
89
 
90
90
  /** 执行限流,返回是否已响应 */
91
91
  const runRateLimit = (rateLimit, req, res) =>
92
- new Promise(rateLimit.bind(null, req, res, r)).then(() => res.headersSent);
92
+ new Promise(resolve => rateLimit(req, res, resolve)).then(() => res.headersSent);
93
93
 
94
94
  /**
95
95
  * 前置WAF中间件(body前)
@@ -162,14 +162,16 @@ const createWafMiddleware = wafConfig => {
162
162
 
163
163
  /**
164
164
  * 已登录用户的管理接口路径前缀(跳过 body 关键词检测,避免误杀富文本/代码内容)
165
+ * 可通过 waf 配置 bodySkipPrefixes 字段自定义,默认包含常见管理模块前缀
165
166
  */
166
- const BODY_CHECK_SKIP_PREFIXES = ["/cms/", "/base/", "/member/", "/book/", "/oss/", "/vip/"];
167
+ const DEFAULT_BODY_SKIP_PREFIXES = ["/cms/", "/base/", "/member/", "/book/", "/oss/", "/vip/"];
167
168
 
168
169
  /**
169
170
  * Body层WAF中间件(body解析后)
170
171
  */
171
172
  const createWafBodyMiddleware = wafConfig => {
172
173
  const blockCfg = { ...DEFAULT_BLOCK, ...wafConfig.block };
174
+ const bodySkipPrefixes = wafConfig.bodySkipPrefixes ?? DEFAULT_BODY_SKIP_PREFIXES;
173
175
 
174
176
  return async (req, res, next) => {
175
177
  try {
@@ -185,7 +187,7 @@ const createWafBodyMiddleware = wafConfig => {
185
187
  if (!req.body || (typeof req.body === "object" && !Object.keys(req.body).length)) return next();
186
188
 
187
189
  // 已登录用户的管理接口:跳过关键词检测(富文本/代码内容会误杀),仅做 XSS 过滤
188
- const isAuthedAdmin = req.user?.uid && BODY_CHECK_SKIP_PREFIXES.some(p => path.startsWith(p));
190
+ const isAuthedAdmin = req.user?.uid && bodySkipPrefixes.some(p => path.startsWith(p));
189
191
 
190
192
  if (!isAuthedAdmin) {
191
193
  // 序列化body文本,截断1w字符防绕过
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.7.5",
4
+ "version": "2.7.6",
5
5
  "description": "chanjs基于express5 纯js研发的轻量级mvc框架。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
package/security/jwt.js CHANGED
@@ -12,12 +12,30 @@ const JWT_ERR_MSG = {
12
12
  };
13
13
 
14
14
  /**
15
- * 校验token是否在黑名单(fail-open:存储异常直接放行)
15
+ * 黑名单校验失败策略配置
16
+ * failOpen=true(默认):存储异常时放行(优先可用性)
17
+ * failOpen=false:存储异常时拦截(优先安全性,适用于金融/权限敏感场景)
18
+ * 可通过环境变量 JWT_REVOCATION_FAIL_CLOSE=true 全局切换为 fail-close
19
+ */
20
+ const IS_FAIL_CLOSE = process.env.JWT_REVOCATION_FAIL_CLOSE === "true";
21
+
22
+ /**
23
+ * 校验token是否在黑名单
24
+ * fail-close 模式:存储异常视为已注销(拦截)
25
+ * fail-open 模式:存储异常视为未注销(放行)
16
26
  */
17
27
  const isTokenRevoked = async token => {
18
28
  if (!token) return false;
19
- try { return !!await store.get(`${REVOKED_KEY_PREFIX}${token}`); }
20
- catch { return false; }
29
+ try {
30
+ return !!await store.get(`${REVOKED_KEY_PREFIX}${token}`);
31
+ } catch (err) {
32
+ if (IS_FAIL_CLOSE) {
33
+ logger.warn(`[JWT] 黑名单查询异常,fail-close 拦截:${err.message}`);
34
+ return true;
35
+ }
36
+ logger.warn(`[JWT] 黑名单查询异常,fail-open 放行:${err.message}`);
37
+ return false;
38
+ }
21
39
  };
22
40
 
23
41
  /**
@@ -3,8 +3,8 @@
3
3
  */
4
4
  const KEYWORD_RULES = Object.freeze({
5
5
  wholeWord: [
6
- "netcat", "nc", "php-cgi", "process", "require", "exec", "import",
7
- "child_process", "execSync", "mainModule"
6
+ "netcat", "php-cgi", "child_process", "execSync", "mainModule",
7
+ "process.env", "require(", "exec("
8
8
  ],
9
9
  extensions: [
10
10
  ".php", ".asp", ".aspx", ".jsp", ".jspx", ".do", ".action", ".cgi",
package/storage/cache.js CHANGED
@@ -46,7 +46,7 @@ class Cache {
46
46
  clear = () => this.map.clear();
47
47
  size() { return this.map.size; }
48
48
 
49
- /** 访问并刷新LRU,过期项自动清理 */
49
+ /** 访问并刷新LRU,过期项自动清理(单次 delete+set 维护 Map 插入序) */
50
50
  _touch(key) {
51
51
  const item = this.map.get(key);
52
52
  if (!item) return null;
@@ -54,6 +54,7 @@ class Cache {
54
54
  this.map.delete(key);
55
55
  return null;
56
56
  }
57
+ // 利用 Map 插入序特性:delete+set 将 key 移至末尾(最近访问)
57
58
  this.map.delete(key);
58
59
  this.map.set(key, item);
59
60
  return item;
@@ -92,13 +93,11 @@ class Cache {
92
93
  return true;
93
94
  }
94
95
 
95
- /** LRU淘汰:循环删除最久未访问key */
96
+ /** LRU淘汰:删除最久未访问key(Map 头部即最早插入) */
96
97
  _evictLRU() {
97
- while (this.map.size >= this.maxSize) {
98
- const oldest = this.map.keys().next().value;
99
- if (oldest === undefined) break;
100
- this.map.delete(oldest);
101
- }
98
+ if (this.map.size < this.maxSize) return;
99
+ const oldest = this.map.keys().next().value;
100
+ if (oldest !== undefined) this.map.delete(oldest);
102
101
  }
103
102
 
104
103
  /** 惰性批量清理:间隔10s一次,单次最多清理1000条防阻塞事件循环 */
package/storage/redis.js CHANGED
@@ -129,30 +129,31 @@ class RedisBackend {
129
129
 
130
130
  /**
131
131
  * 自增计数,新建key使用全局默认TTL
132
- * 纯ioredis方案:先INCR,返回1(key新建/过期重建)则补PEXPIRE,递增不刷新TTL
133
- * 解决原 SET NX + INCR 之间 key 过期导致的无 TTL 内存泄漏竞态
132
+ * Lua 脚本保证 INCR + PEXPIRE 原子性,避免崩溃窗口内 keyTTL 内存泄漏
133
+ * 递增不刷新TTL,仅首次(count===1)设置过期
134
134
  * @param {string} key 计数key
135
135
  * @returns {number} 当前计数值
136
136
  */
137
137
  async incr(key) {
138
138
  const cli = await this._getClient();
139
- const count = await cli.incr(key);
140
- // count===1 说明 key 是新建的(首次或过期重建),补设 TTL;递增(count>1)不刷新 TTL
141
- if (count === 1) await cli.pexpire(key, REDIS_CONST.DEFAULT_INCR_TTL);
142
- return count;
139
+ return cli.eval(
140
+ "local c=redis.call('INCR',KEYS[1]);if c==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1])end;return c",
141
+ 1, key, REDIS_CONST.DEFAULT_INCR_TTL
142
+ );
143
143
  }
144
144
 
145
145
  /**
146
- * 限流专用自增,自定义新建key过期时间
146
+ * 限流专用自增,自定义新建key过期时间(Lua 原子操作)
147
147
  * @param {string} key 计数key
148
148
  * @param {number} ttlMs 窗口过期时间
149
149
  * @returns {number} 当前计数值
150
150
  */
151
151
  async incrAndExpire(key, ttlMs) {
152
152
  const cli = await this._getClient();
153
- const count = await cli.incr(key);
154
- if (count === 1) await cli.pexpire(key, ttlMs);
155
- return count;
153
+ return cli.eval(
154
+ "local c=redis.call('INCR',KEYS[1]);if c==1 then redis.call('PEXPIRE',KEYS[1],ARGV[1])end;return c",
155
+ 1, key, ttlMs
156
+ );
156
157
  }
157
158
 
158
159
  /**
package/utils/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * 工具聚合层
3
3
  *
4
- * 业务侧使用方式:
5
- * import { helper } from "chanjs";
6
- * const { tree, getIp, formatDateFields } = helper;
4
+ * 业务侧使用方式(精确命名导入):
5
+ * import { tree, getIp, formatDateFields } from "chanjs/utils/index.js";
7
6
  *
8
- * 也可直接从子模块导入(精确引用):
7
+ * 也可从子模块单独导入:
9
8
  * import { getIp } from "chanjs/utils/ip.js";
10
9
  */
11
10
 
package/utils/request.js CHANGED
@@ -88,6 +88,7 @@ export async function request(url, options = {}) {
88
88
 
89
89
  try {
90
90
  const res = await fetch(rawUrl, { ...fetchOpts, signal: controller.signal });
91
+ if (timer) clearTimeout(timer);
91
92
 
92
93
  if (!res.ok) {
93
94
  return { success: false, error: `HTTP ${res.status} ${res.statusText}` };