mm_quota 0.0.0-stage → 1.0.0

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
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # mm_quota 配额模块
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 固定时间窗口配额:按 key(用户 id、应用 id 等)统计窗口内用量,超出 `limit` 拒绝,窗口结束后自动重置。适用于 API 调用次数、存储容量等配额场景。
4
+
5
+ ## 快速开始
6
+
7
+ ```javascript
8
+ const { quotaAdmin } = require('mm_quota');
9
+
10
+ const quota = quotaAdmin('web', { limit: 1000, window: 3600000 }); // 每小时 1000 次
11
+
12
+ const q = quota.consume(ctx.user_id);
13
+ if (!q.allowed) {
14
+ ctx.status = 429;
15
+ ctx.retry_after = q.reset_at - Date.now(); // 窗口重置时间
16
+ return;
17
+ }
18
+ ```
19
+
20
+ ## API
21
+
22
+ | 方法 | 说明 |
23
+ | --- | --- |
24
+ | `consume(key, cost?)` | 消费配额,返回 `{ allowed, used, limit, remaining, reset_at }` |
25
+ | `status(key)` | 查询配额状态(不消费) |
26
+ | `reset(key)` | 重置某个 key 的配额 |
27
+ | `clear()` | 清空全部记录 |
28
+
29
+ 默认配置:`limit: 1000`、`window: 3600000`(1 小时)、`max_keys: 10000`(超限淘汰最久未用的记录)。
30
+
31
+ `quotaAdmin(scope, config)` 按作用域缓存实例,相同 `scope` 返回同一实例。
package/index.js ADDED
@@ -0,0 +1,25 @@
1
+ const { Quota } = require('./lib/quota.js');
2
+ const Admin = require('mm_corejs').Admin;
3
+
4
+ /**
5
+ * 配额管理 Admin 工厂
6
+ * 支持 os.js 调用:require('mm_quota').admin.setServer(os) -> admin.get(scope, config)
7
+ */
8
+ let admin = new Admin();
9
+ admin.setClass(Quota);
10
+
11
+ /**
12
+ * 获取(或创建)指定作用域的配额实例
13
+ * @param {string} [scope] 作用域,缺省 'web'
14
+ * @param {Object} [config] 配置参数
15
+ * @returns {Quota} 配额实例
16
+ */
17
+ function quotaAdmin(scope, config) {
18
+ return admin.get(scope || 'web', config);
19
+ }
20
+
21
+ module.exports = {
22
+ Quota,
23
+ admin,
24
+ quotaAdmin
25
+ };
package/lib/quota.js ADDED
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const { Base } = require('mm_mod');
4
+
5
+ /**
6
+ * 配额管理类
7
+ *
8
+ * 固定时间窗口配额:按 key 统计窗口内的用量,超出 limit 拒绝,
9
+ * 窗口结束后自动重置。适用于 API 调用次数、存储容量等配额场景。
10
+ */
11
+ class Quota extends Base {
12
+ /**
13
+ * 模块默认配置
14
+ */
15
+ static config = {
16
+ name: 'quota',
17
+ title: '配额管理模块',
18
+ description: '固定时间窗口配额:按 key 统计用量,超限拒绝,窗口自动重置',
19
+ // 窗口内允许的总用量
20
+ limit: 1000,
21
+ // 窗口时长(毫秒)
22
+ window: 3600000,
23
+ // 最大跟踪 key 数,超出后淘汰最久未使用的记录
24
+ max_keys: 10000
25
+ };
26
+
27
+ /**
28
+ * 配额记录:key -> { used, window_start }
29
+ * @type {Map<string, Object>}
30
+ */
31
+ #records = new Map();
32
+
33
+ /**
34
+ * 消费配额
35
+ * @param {string} key 配额维度(用户 id、应用 id 等)
36
+ * @param {number} [cost] 本次消耗量,默认 1
37
+ * @returns {Object} 结果 { allowed, used, limit, remaining, reset_at }
38
+ */
39
+ consume(key, cost) {
40
+ if (!key || typeof key !== 'string') {
41
+ throw new TypeError(`${this._label()} key must be a non-empty string`);
42
+ }
43
+ const want = cost === undefined ? 1 : cost;
44
+ if (typeof want !== 'number' || !Number.isFinite(want) || want <= 0) {
45
+ throw new TypeError(`${this._label()} cost must be a positive finite number`);
46
+ }
47
+ const now = Date.now();
48
+ let record = this.#records.get(key);
49
+ if (!record) {
50
+ if (this.#records.size >= this.config.max_keys) {
51
+ let oldest_key = null;
52
+ let oldest = Infinity;
53
+ for (const [k, r] of this.#records) {
54
+ if (r.window_start < oldest) {
55
+ oldest = r.window_start;
56
+ oldest_key = k;
57
+ }
58
+ }
59
+ if (oldest_key !== null) {
60
+ this.#records.delete(oldest_key);
61
+ }
62
+ }
63
+ record = { used: 0, window_start: now };
64
+ this.#records.set(key, record);
65
+ }
66
+
67
+ // 窗口已过,自动重置
68
+ if (now - record.window_start >= this.config.window) {
69
+ record.used = 0;
70
+ record.window_start = now;
71
+ }
72
+
73
+ const allowed = record.used + want <= this.config.limit;
74
+ if (allowed) {
75
+ record.used += want;
76
+ }
77
+ return {
78
+ allowed,
79
+ used: record.used,
80
+ limit: this.config.limit,
81
+ remaining: Math.max(0, this.config.limit - record.used),
82
+ reset_at: record.window_start + this.config.window
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 查询配额状态(不消费)
88
+ * @param {string} key 配额维度
89
+ * @returns {Object} 状态 { used, limit, remaining, reset_at };未使用过时 used 为 0
90
+ */
91
+ status(key) {
92
+ const record = this.#records.get(key);
93
+ const now = Date.now();
94
+ if (!record || now - record.window_start >= this.config.window) {
95
+ return { used: 0, limit: this.config.limit, remaining: this.config.limit, reset_at: null };
96
+ }
97
+ return {
98
+ used: record.used,
99
+ limit: this.config.limit,
100
+ remaining: Math.max(0, this.config.limit - record.used),
101
+ reset_at: record.window_start + this.config.window
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 重置某个 key 的配额
107
+ * @param {string} key 配额维度
108
+ * @returns {boolean} 是否删除成功
109
+ */
110
+ reset(key) {
111
+ return this.#records.delete(key);
112
+ }
113
+
114
+ /**
115
+ * 清空全部配额记录
116
+ * @returns {Quota} 当前实例
117
+ */
118
+ clear() {
119
+ this.#records.clear();
120
+ return this;
121
+ }
122
+ }
123
+
124
+ module.exports = { Quota };
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "mm_quota",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "超级美眉配额模块:固定时间窗口配额,按 key 统计用量,超限拒绝,窗口自动重置",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node test.js && node --test tests/quota.test.js"
8
+ },
9
+ "keywords": [
10
+ "mm_mod",
11
+ "mm_middleware",
12
+ "quota",
13
+ "limit",
14
+ "window"
15
+ ],
16
+ "layer": "middleware",
17
+ "group": "web",
18
+ "author": "邱文武",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "mm_corejs": "^1.1.4",
22
+ "mm_mod": "^1.0.1"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }
package/test.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ const assert = require('assert');
3
+ const mod = require('./index.js');
4
+
5
+ assert.strictEqual(typeof mod.Quota, 'function', '应导出 Quota 类');
6
+ assert.strictEqual(typeof mod.quotaAdmin, 'function', '应导出 quotaAdmin 函数');
7
+
8
+ const q = mod.quotaAdmin('smoke', { limit: 2, window: 60000 });
9
+ assert.ok(q instanceof mod.Quota);
10
+ assert.strictEqual(q.consume('k').allowed, true);
11
+ assert.strictEqual(q.consume('k').allowed, true);
12
+ assert.strictEqual(q.consume('k').allowed, false, '超出配额后应拒绝');
13
+
14
+ console.log('mm_quota 模块测试通过');
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ /**
3
+ * mm_quota 单元测试
4
+ */
5
+ const test = require('node:test');
6
+ const assert = require('assert');
7
+
8
+ const { Quota, quotaAdmin } = require('../index.js');
9
+
10
+ test('基本配额:窗口内累计,超限拒绝', () => {
11
+ const q = new Quota({ limit: 5, window: 60000 });
12
+ assert.strictEqual(q.consume('u1').allowed, true);
13
+ assert.strictEqual(q.consume('u1', 3).allowed, true);
14
+ const denied = q.consume('u1', 2); // 已用 4,再要 2 超限
15
+ assert.strictEqual(denied.allowed, false);
16
+ assert.strictEqual(denied.used, 4);
17
+ assert.strictEqual(denied.remaining, 1);
18
+ assert.strictEqual(q.consume('u1').allowed, true); // 剩余 1 可用
19
+ assert.strictEqual(q.consume('u1').allowed, false); // 已满
20
+ assert.strictEqual(denied.limit, 5);
21
+ assert.ok(denied.reset_at > Date.now(), 'reset_at 应指向窗口结束时间');
22
+ });
23
+
24
+ test('不同 key 相互隔离', () => {
25
+ const q = new Quota({ limit: 1, window: 60000 });
26
+ assert.strictEqual(q.consume('a').allowed, true);
27
+ assert.strictEqual(q.consume('a').allowed, false);
28
+ assert.strictEqual(q.consume('b').allowed, true);
29
+ });
30
+
31
+ test('cost 参数校验', () => {
32
+ const q = new Quota({ limit: 10, window: 60000 });
33
+ assert.throws(() => q.consume('k', 0), TypeError);
34
+ assert.throws(() => q.consume('k', -5), TypeError);
35
+ assert.throws(() => q.consume('k', NaN), TypeError);
36
+ assert.throws(() => q.consume(''), TypeError);
37
+ });
38
+
39
+ test('status 查询不消费', () => {
40
+ const q = new Quota({ limit: 10, window: 60000 });
41
+ assert.strictEqual(q.status('fresh').used, 0);
42
+ q.consume('fresh', 4);
43
+ const s = q.status('fresh');
44
+ assert.strictEqual(s.used, 4);
45
+ assert.strictEqual(s.remaining, 6);
46
+ assert.strictEqual(q.status('fresh').used, 4, 'status 不应改变用量');
47
+ q.consume('fresh', 1);
48
+ assert.strictEqual(q.status('fresh').used, 5);
49
+ });
50
+
51
+ test('窗口过期自动重置', async () => {
52
+ const q = new Quota({ limit: 1, window: 50 });
53
+ assert.strictEqual(q.consume('w').allowed, true);
54
+ assert.strictEqual(q.consume('w').allowed, false);
55
+ await new Promise((r) => setTimeout(r, 70));
56
+ assert.strictEqual(q.consume('w').allowed, true, '窗口过期后应重置');
57
+ });
58
+
59
+ test('reset / clear 与 max_keys 淘汰', async () => {
60
+ const tick = () => new Promise((r) => setTimeout(r, 3));
61
+ // max_keys 淘汰:按 window_start 淘汰最早创建的记录
62
+ const q = new Quota({ limit: 1, window: 60000, max_keys: 2 });
63
+ q.consume('a');
64
+ await tick();
65
+ q.consume('b');
66
+ await tick();
67
+ q.consume('c'); // 淘汰 a
68
+ assert.strictEqual(q.status('a').used, 0, 'a 被淘汰');
69
+ assert.strictEqual(q.status('b').used, 1, 'b 保留');
70
+ // reset / clear
71
+ assert.strictEqual(q.reset('b'), true);
72
+ assert.strictEqual(q.reset('nope'), false);
73
+ assert.strictEqual(q.status('b').used, 0);
74
+ q.clear();
75
+ assert.strictEqual(q.status('c').used, 0);
76
+ });
77
+
78
+ test('工厂函数按作用域共享实例', () => {
79
+ const a = quotaAdmin('ut-quota', {});
80
+ const b = quotaAdmin('ut-quota');
81
+ assert.strictEqual(a, b);
82
+ assert.notStrictEqual(quotaAdmin('ut-quota-2'), a);
83
+ });