@oj-module/cli 0.1.20 → 0.2.1
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/devkit/README.md +1 -1
- package/devkit/SKILL.md +17 -3
- package/devkit/manual.md +91 -6
- package/package.json +2 -2
- package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
- package/shell-dist/assets/runtime.js +248 -89
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/build.ts +27 -0
- package/src/init.ts +3 -0
- package/templates/README.md +15 -0
- package/templates/api/config.yaml +14 -0
- package/templates/api/seed.mysql.sql +5 -0
- package/templates/api/seed.pg.sql +5 -0
- package/templates/api/src/_platform/_shared/respond.ts +18 -0
- package/templates/api/src/_platform/_shared/service/access.ts +99 -0
- package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
- package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
- package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
- package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
- package/templates/api/src/_platform/_shared/service/query.ts +53 -0
- package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
- package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
- package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
- package/templates/api/src/_platform/_shared/service/users.ts +163 -0
- package/templates/api/src/_platform/contacts/api.ts +22 -0
- package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
- package/templates/api/src/_platform/manifest.yaml +9 -1
- package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
- package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
- package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
- package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
- package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
- package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
- package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
- package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
- package/templates/api/src/_platform/schema.yaml +76 -1
- package/templates/api/src/_platform/seed.mysql.sql +8 -0
- package/templates/api/src/_platform/seed.pg.sql +8 -0
- package/templates/api/src/_platform/seed.sql +19 -2
- package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
- package/templates/api/src/auth/_shared/ldap.ts +14 -0
- package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
- package/templates/api/src/auth/_shared/session.ts +163 -18
- package/templates/api/src/auth/api.ts +54 -44
- package/templates/api/src/auth/ldap-login/api.ts +29 -0
- package/templates/api/src/system/login-methods/api.ts +19 -0
- package/templates/api/src/system/manifest.yaml +5 -0
- package/templates/api/src/system/oplog/api.ts +53 -0
- package/templates/api/src/system/permissions/api.ts +26 -0
- package/templates/api/src/system/roles/api.ts +75 -0
- package/templates/api/src/system/roles/bindings/api.ts +29 -0
- package/templates/api/src/system/settings/api.ts +60 -0
- package/templates/api/src/system/users/api.ts +58 -0
- package/templates/api/src/system/users/forbidden/api.ts +26 -0
- package/templates/api/src/system/users/password/api.ts +26 -0
- package/templates/api/src/system/users/roles/api.ts +27 -0
- package/templates/api/src/web/user-info/api.ts +9 -18
- package/templates/api/tests/auth-ldap.spec.ts +173 -0
- package/templates/api/tests/auth-session.spec.ts +194 -0
- package/templates/api/tests/auth-throttle.spec.ts +74 -0
- package/templates/api/tests/fixture.ts +50 -0
- package/templates/api/tests/guard.spec.ts +62 -0
- package/templates/api/tests/query-utils.spec.ts +68 -0
- package/templates/api/tests/rbac-audit.spec.ts +105 -0
- package/templates/api/tests/rbac-contacts.spec.ts +85 -0
- package/templates/api/tests/rbac-guard.spec.ts +192 -0
- package/templates/api/tests/rbac-roles.spec.ts +159 -0
- package/templates/api/tests/rbac-seed.spec.ts +54 -0
- package/templates/api/tests/rbac-settings.spec.ts +79 -0
- package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
- package/templates/api/tests/rbac-tables.spec.ts +63 -0
- package/templates/api/tests/rbac-users.spec.ts +164 -0
- package/templates/api/vitest.config.ts +12 -0
- package/templates/openapi.json +1060 -31
- package/templates/pnpm-workspace.yaml +9 -0
- package/templates/web/src/auth/__client/api.schemas.ts +6 -1
- package/templates/web/src/auth/__client/api.ts +33 -1
- package/templates/web/src/login/entry.ts +17 -0
- package/templates/web/src/login/locales/en-US.json +12 -2
- package/templates/web/src/login/locales/zh-CN.json +12 -2
- package/templates/web/src/login/pages/change-password.tsx +74 -0
- package/templates/web/src/system/entry.ts +90 -0
- package/templates/web/src/system/locales/en-US.json +98 -0
- package/templates/web/src/system/locales/zh-CN.json +98 -0
- package/templates/web/src/system/pages/errors.ts +51 -0
- package/templates/web/src/system/pages/forbidden.tsx +35 -0
- package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
- package/templates/web/src/system/pages/oplog/index.tsx +91 -0
- package/templates/web/src/system/pages/roles/index.tsx +91 -0
- package/templates/web/src/system/pages/settings/index.tsx +106 -0
- package/templates/web/src/system/pages/users/index.tsx +168 -0
- package/templates/web/src/system/pages/users/roles.tsx +68 -0
- package/templates/web.config.ts +8 -0
- package/vendor/host-versions.json +3 -3
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-mWZBL1v96uMHEClxtdmRpA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="
|
|
10
|
+
<script type="importmap" nonce="mWZBL1v96uMHEClxtdmRpA==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"dayjs/locale/zh-cn": "/assets/ojm-dayjs-locale-zh-cn.js"
|
|
134
134
|
}
|
|
135
135
|
}</script>
|
|
136
|
-
<script type="module" crossorigin src="/assets/index-
|
|
136
|
+
<script type="module" crossorigin src="/assets/index-CLdhl3Qd.js"></script>
|
|
137
137
|
</head>
|
|
138
138
|
<body>
|
|
139
139
|
<div id="root"></div>
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.1
|
|
11
|
-
"@oj-module/runtime/contract": "0.1
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.1
|
|
10
|
+
"@oj-module/runtime": "0.2.1",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.1",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.1",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
package/src/build.ts
CHANGED
|
@@ -60,6 +60,31 @@ export const fetchMenuList = _fn;
|
|
|
60
60
|
export const fetchAddMenuItem = _fn;
|
|
61
61
|
export const fetchUpdateMenuItem = _fn;
|
|
62
62
|
export const fetchDeleteMenuItem = _fn;
|
|
63
|
+
// RBAC(阶段 C 增补,与 runtime api/rbac 对齐)
|
|
64
|
+
export const fetchRbacRoleList = _fn;
|
|
65
|
+
export const fetchRbacPermissionList = _fn;
|
|
66
|
+
export const fetchRbacCreateRole = _fn;
|
|
67
|
+
export const fetchRbacUpdateBindings = _fn;
|
|
68
|
+
export const fetchRbacDeleteRole = _fn;
|
|
69
|
+
export const fetchRbacUserList = _fn;
|
|
70
|
+
export const fetchRbacCreateUser = _fn;
|
|
71
|
+
export const fetchRbacUpdateUserRoles = _fn;
|
|
72
|
+
export const fetchRbacAdminResetPassword = _fn;
|
|
73
|
+
export const fetchRbacSetForbidden = _fn;
|
|
74
|
+
export const fetchRbacOplog = _fn;
|
|
75
|
+
export const fetchRbacSettings = _fn;
|
|
76
|
+
export const fetchRbacUpdateSetting = _fn;
|
|
77
|
+
export const fetchRbacContacts = _fn;
|
|
78
|
+
export const fetchRbacContactDetail = _fn;
|
|
79
|
+
export const fetchLoginMethods = _fn;
|
|
80
|
+
export const fetchChangePassword = _fn;
|
|
81
|
+
export const fetchLdapLogin = _fn;
|
|
82
|
+
export const roleLabel = _fn;
|
|
83
|
+
export const canEditRoleBindings = _fn;
|
|
84
|
+
export const canDeleteRole = _fn;
|
|
85
|
+
export const canAssignRole = _fn;
|
|
86
|
+
export const bindingDiff = _fn;
|
|
87
|
+
export const groupByModule = _fn;
|
|
63
88
|
// hooks 与权限常量
|
|
64
89
|
export const useAccess = _fn;
|
|
65
90
|
export const usePreferences = _fn;
|
|
@@ -74,6 +99,8 @@ export const permissionPrefix = "permission:button";
|
|
|
74
99
|
export const useAuthStore = _fn;
|
|
75
100
|
export const useUserStore = _fn;
|
|
76
101
|
export const useTabsStore = _fn;
|
|
102
|
+
// 页签 key 唯一正确取口(keepAlive 下 useLocation 是激活页位置,0.1.21)
|
|
103
|
+
export const useTabKey = _fn;
|
|
77
104
|
// 图标
|
|
78
105
|
export const menuIcons = _obj;
|
|
79
106
|
export const EmbeddedIcon = _fn;
|
package/src/init.ts
CHANGED
|
@@ -328,8 +328,11 @@ function generatePackageJson(cliRoot: string, projectName: string) {
|
|
|
328
328
|
"@ant-design/icons": pin("@ant-design/icons"),
|
|
329
329
|
// personal-center 的 ProForm 用到——仅取**类型**(运行时由宿主 importmap 提供单例)
|
|
330
330
|
"@ant-design/pro-components": pin("@ant-design/pro-components"),
|
|
331
|
+
// system 模块页面数据层 + i18n 类型(TFunction)用到——仅取**类型**(运行时由宿主提供单例)
|
|
332
|
+
"@tanstack/react-query": pin("@tanstack/react-query"),
|
|
331
333
|
"@types/react": pin("@types/react"),
|
|
332
334
|
"antd": pin("antd"),
|
|
335
|
+
"i18next": pin("i18next"),
|
|
333
336
|
// home 首页图表演示用到——仅取**类型**(运行时由宿主 importmap 提供单例)
|
|
334
337
|
"dayjs": pin("dayjs"),
|
|
335
338
|
"echarts": pin("echarts"),
|
package/templates/README.md
CHANGED
|
@@ -13,6 +13,21 @@ pnpm preview # 预览:oj migrate + server(release 产物)
|
|
|
13
13
|
|
|
14
14
|
登录账号:`admin` / `123456`(见 `api/src/_platform/seed.sql`)。
|
|
15
15
|
|
|
16
|
+
## 系统管理模块
|
|
17
|
+
|
|
18
|
+
`web/src/system/`(用户管理 / 成员资格 / 角色权限 / 操作日志 / 站点设置 / 登录方式)
|
|
19
|
+
的前端 API 客户端 `__client/` 是生成物、不随模板分发,**首次使用前生成一次**:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm dev # 终端 A:起 oj 后端 + 前端
|
|
23
|
+
bin/oj openapi -c config.yaml -d api/src -b /api -o openapi.json # 终端 B:产出契约
|
|
24
|
+
pnpm exec ojm api # 生成 web/src/*/__client(dev 检测到 openapi.json 变化也会自动重生成)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
生成后重启 `pnpm dev`,「系统管理」菜单即可用(生成前该模块加载失败属预期)。
|
|
28
|
+
改了 `api/src` 的 handler `.schema`/`.response` 后同样重跑这两条保持同步
|
|
29
|
+
(`ojm api --check` 可对账,零漂移为发布门禁)。成员资格页仅 superadmin 可见。
|
|
30
|
+
|
|
16
31
|
## 目录
|
|
17
32
|
|
|
18
33
|
```
|
|
@@ -42,11 +42,25 @@ server:
|
|
|
42
42
|
credentials: false
|
|
43
43
|
db:
|
|
44
44
|
default: 'sqlite://db.sqlite' # 相对 config 目录;缺文件自动建空库
|
|
45
|
+
|
|
46
|
+
# ---- LDAP 连接段(D1/PRD §4.5 两段式之连接段;oj 插件严格解析本段,未知键启动 fail-fast——只写下列键)----
|
|
47
|
+
# 取消注释并填值即接通目录服务;应用映射段在 DB/设置页(ldap.enabled、ldap_base_dn、
|
|
48
|
+
# ldap_uid_attr、ldap_email_attr,group=ldap,即时生效)。本段改动属 restart_pending,需重启。
|
|
49
|
+
# ldap:
|
|
50
|
+
# url: "ldap://127.0.0.1:389"
|
|
51
|
+
# bind_dn: ""
|
|
52
|
+
# bind_pw: ""
|
|
53
|
+
# timeout_ms: 3000
|
|
54
|
+
# start_tls: false
|
|
55
|
+
# tls_skip_verify: false
|
|
45
56
|
auth:
|
|
46
57
|
jwt_secret: __JWT_SECRET__ # init 随机生成;生产必须更换
|
|
47
58
|
access_token_duration: 2h
|
|
48
59
|
refresh_token_duration: 720h
|
|
49
60
|
anonymous_paths: # auth 端点是业务路由,必须显式匿名(漏列则握手 401,见 ojm 开发手册 §8)
|
|
50
61
|
- /auth/login
|
|
62
|
+
- /auth/ldap-login # D1/AC1-12:LDAP 目录登录(匿名)
|
|
51
63
|
- /auth/refresh
|
|
52
64
|
- /auth/logout
|
|
65
|
+
# 登录页登录方式 Tabs 数据源(C5/AC2-2):未登录即可读
|
|
66
|
+
- /system/login-methods
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- 系统设置默认值(PRD §4.3;注释内禁英文分号)
|
|
2
|
+
INSERT IGNORE INTO sys_setting (key, module, `group`, type, value, default_value, label_key, secret, update_time) VALUES ('framework.site_title', 'framework', 'general', 'string', 'React Antd Admin', 'React Antd Admin', 'system:setting.site_title', 0, 0);
|
|
3
|
+
INSERT IGNORE INTO sys_setting (key, module, `group`, type, value, default_value, label_key, secret, update_time) VALUES ('market.cdn_url', 'market', 'security', 'string', '', '', 'system:setting.market_cdn_url', 0, 0);
|
|
4
|
+
INSERT IGNORE INTO sys_setting (key, module, `group`, type, value, default_value, label_key, secret, update_time) VALUES ('ldap.enabled', 'ldap', 'login', 'bool', '0', '0', 'system:setting.ldap_enabled', 0, 0);
|
|
5
|
+
INSERT IGNORE INTO sys_setting (key, module, `group`, type, value, default_value, label_key, secret, update_time) VALUES ('sso.enabled', 'sso', 'login', 'bool', '0', '0', 'system:setting.sso_enabled', 0, 0);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- 系统设置默认值(PRD §4.3;注释内禁英文分号)
|
|
2
|
+
INSERT INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('framework.site_title', 'framework', 'general', 'string', 'React Antd Admin', 'React Antd Admin', 'system:setting.site_title', 0, 0) ON CONFLICT DO NOTHING;
|
|
3
|
+
INSERT INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('market.cdn_url', 'market', 'security', 'string', '', '', 'system:setting.market_cdn_url', 0, 0) ON CONFLICT DO NOTHING;
|
|
4
|
+
INSERT INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('ldap.enabled', 'ldap', 'login', 'bool', '0', '0', 'system:setting.ldap_enabled', 0, 0) ON CONFLICT DO NOTHING;
|
|
5
|
+
INSERT INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('sso.enabled', 'sso', 'login', 'bool', '0', '0', 'system:setting.sso_enabled', 0, 0) ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 统一异常出口(handler 层编排约定):
|
|
2
|
+
// service / 守卫层抛 Error & { status?, ...extra }(见 service/roles.ts fail()),
|
|
3
|
+
// 但 oj 运行时对未捕获异常一律落 500 信封(不读 err.status)——
|
|
4
|
+
// 所有 handler 的业务体必须经 respond 收口成 json.fail(status, msg, extra),
|
|
5
|
+
// 否则 D14 统一 401 / B5 429 retryAfterMs / 403 must_change / 409 holders 全被抹成 500。
|
|
6
|
+
export async function respond<T>(fn: () => Promise<T>): Promise<void> {
|
|
7
|
+
try {
|
|
8
|
+
json.ok(await fn());
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
const err = e as Error & { status?: number } & Record<string, unknown>;
|
|
12
|
+
const data = Object.fromEntries(Object.entries(err).filter(([k]) => k !== "status" && k !== "message"));
|
|
13
|
+
if (Object.keys(data).length > 0)
|
|
14
|
+
json.fail(err.status ?? 500, err.message, data);
|
|
15
|
+
else
|
|
16
|
+
json.fail(err.status ?? 500, err.message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 判权应用出口(D10):DB 权威源 + 短 TTL 缓存 + 写时失效 = 撤销即强退。
|
|
2
|
+
// 领域决策在 guard.ts(decideAccess 纯函数,AC1-1~6 矩阵);本层只做 IO:加载/缓存/失效/抛错。
|
|
3
|
+
// 错误约定:拒权抛 Error & { status: 403 }(应用 handler 层映射 json.fail,与 oj-wiki 先例一致)。
|
|
4
|
+
// 反常识注记:oj 每请求重建模块实例,本缓存恒不命中(每请求直查 DB)——正确性无损
|
|
5
|
+
// (撤销即强退反而成为平凡真),缓存/写时失效仅在同请求多断言时有意义;保留以兼容调用面。
|
|
6
|
+
import { decideAccess } from "./guard";
|
|
7
|
+
|
|
8
|
+
/** 短 TTL 仅兜底(进程崩溃/漏失效),正常撤销走写时 invalidateUser(设计文档 D10)。 */
|
|
9
|
+
const TTL_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
interface AccessSnapshot {
|
|
12
|
+
roles: string[]
|
|
13
|
+
bindings: Record<string, string[]>
|
|
14
|
+
forbidden: boolean
|
|
15
|
+
/** 首登/重置口令后的强制改密悬挂(D12/AC1-27):悬挂期判权出口全拒(改密端点不经过本层) */
|
|
16
|
+
mustChange: boolean
|
|
17
|
+
at: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const cache = new Map<number, AccessSnapshot>();
|
|
21
|
+
|
|
22
|
+
async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
23
|
+
const userRows = await db.query("select is_forbidden, must_change from users where id = ?", [userId]);
|
|
24
|
+
const roleRows = await db.query("select role_name from sys_user_role where user_id = ?", [userId]);
|
|
25
|
+
const roles = roleRows.map((r: { role_name: string }) => r.role_name);
|
|
26
|
+
// 只捞该用户角色的绑定(参数化 IN,三方言纪律);deprecated 不过滤(判权职责,§5)
|
|
27
|
+
const bindRows = roles.length
|
|
28
|
+
? await db.query(
|
|
29
|
+
`select role_name, permission_code from sys_role_permission where role_name in (${roles.map(() => "?").join(",")})`,
|
|
30
|
+
roles,
|
|
31
|
+
)
|
|
32
|
+
: [];
|
|
33
|
+
const bindings: Record<string, string[]> = {};
|
|
34
|
+
for (const r of bindRows as { role_name: string, permission_code: string }[])
|
|
35
|
+
(bindings[r.role_name] ??= []).push(r.permission_code);
|
|
36
|
+
return {
|
|
37
|
+
roles,
|
|
38
|
+
bindings,
|
|
39
|
+
forbidden: Number((userRows[0] || {}).is_forbidden) === 1,
|
|
40
|
+
mustChange: Number((userRows[0] || {}).must_change) === 1,
|
|
41
|
+
at: Date.now(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function snapshot(userId: number): Promise<AccessSnapshot> {
|
|
46
|
+
const hit = cache.get(userId);
|
|
47
|
+
if (hit && Date.now() - hit.at < TTL_MS)
|
|
48
|
+
return hit;
|
|
49
|
+
const snap = await loadSnapshot(userId);
|
|
50
|
+
cache.set(userId, snap);
|
|
51
|
+
return snap;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 写路径(角色分配/绑定调整/紧急吊销/禁用)同步调用:撤销下一次请求即生效。 */
|
|
55
|
+
export function invalidateUser(userId: number): void {
|
|
56
|
+
cache.delete(userId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function invalidateAllUsers(): void {
|
|
60
|
+
cache.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function deny(): never {
|
|
64
|
+
throw Object.assign(new Error("forbidden"), { status: 403 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 当前请求的访问快照;未认证/禁用/改密悬挂即拒(403 不泄露账号存在性)。 */
|
|
68
|
+
async function currentAccess(): Promise<AccessSnapshot> {
|
|
69
|
+
const uid = http.user?.id;
|
|
70
|
+
if (uid == null)
|
|
71
|
+
deny();
|
|
72
|
+
const snap = await snapshot(uid);
|
|
73
|
+
if (snap.forbidden)
|
|
74
|
+
deny();
|
|
75
|
+
if (snap.mustChange)
|
|
76
|
+
throw Object.assign(new Error("password change required"), { status: 403, code: "must_change" });
|
|
77
|
+
return snap;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 四级链判权(AC1-1~6)。code 首段由 guard.moduleOf 在 JS 侧切(防三方言字符串函数分叉)。 */
|
|
81
|
+
export async function assertPermission(code: string): Promise<void> {
|
|
82
|
+
const { roles, bindings } = await currentAccess();
|
|
83
|
+
if (!decideAccess({ roles, bindings }, code))
|
|
84
|
+
deny();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 系统管理员及以上(superadmin 或 admin)。 */
|
|
88
|
+
export async function assertSystemAdmin(): Promise<void> {
|
|
89
|
+
const { roles } = await currentAccess();
|
|
90
|
+
if (!roles.includes("superadmin") && !roles.includes("admin"))
|
|
91
|
+
deny();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 仅超级管理员:成员资格管理/卸载/市场安全设置/审计导出等安全敏感项(D11,这些项不落权限点表)。 */
|
|
95
|
+
export async function assertSuperAdmin(): Promise<void> {
|
|
96
|
+
const { roles } = await currentAccess();
|
|
97
|
+
if (!roles.includes("superadmin"))
|
|
98
|
+
deny();
|
|
99
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// 审计出口(AC1-29):recordOp 落 sys_oplog;auditOp fire-and-forget(失败静默,不阻断业务);
|
|
2
|
+
// auditDeny 按 (user_id, code) 分钟窗口聚合——首条落表,窗口内只更新计数,1000 次循环不逐条落表。
|
|
3
|
+
// 反常识修正:oj 每请求重建模块实例,聚合窗口状态以 sys_oplog 自身为锚(分钟内最近一条同 (user,code)
|
|
4
|
+
// 的 auth.deny 行),进程内 Map 方案在真实运行时每请求恒为空、恒逐条落表(语义破坏已实测)。
|
|
5
|
+
// 脱敏红线:detail 由调用方保证不含口令/密钥/token(本层只做 JSON 序列化)。
|
|
6
|
+
import { moduleOf } from "./guard";
|
|
7
|
+
|
|
8
|
+
export interface OpEntry {
|
|
9
|
+
/** 动作标识,点分:`role.create` / `user.roles.update`(C 区页面筛选项) */
|
|
10
|
+
action: string
|
|
11
|
+
/** 归属模块(sys_oplog.module;聚合/筛选用) */
|
|
12
|
+
module: string
|
|
13
|
+
version?: string
|
|
14
|
+
/** 操作对象标识(角色名/用户名/方案名),可空 */
|
|
15
|
+
target?: string
|
|
16
|
+
/** 结构化明细;本层 JSON 序列化。脱敏红线:不得含口令/密钥/token */
|
|
17
|
+
detail?: unknown
|
|
18
|
+
/** syslog 级,默认 6(info);403 拒绝为 4(warning) */
|
|
19
|
+
level?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function usernameOf(userId: number): Promise<string> {
|
|
23
|
+
if (!userId)
|
|
24
|
+
return "system";
|
|
25
|
+
const rows = await db.query("select username from users where id = ?", [userId]);
|
|
26
|
+
return rows[0] ? String(rows[0].username) : "unknown";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 同步落表。关键路径(如 A6 角色管理)直接 await;一般路径用 auditOp。 */
|
|
30
|
+
export async function recordOp(entry: OpEntry): Promise<void> {
|
|
31
|
+
const uid = http.user?.id ?? 0;
|
|
32
|
+
await db.exec(
|
|
33
|
+
"insert into sys_oplog (create_time, user_id, username, action, module, version, target, detail, level) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
34
|
+
[
|
|
35
|
+
Date.now(),
|
|
36
|
+
uid,
|
|
37
|
+
await usernameOf(uid),
|
|
38
|
+
entry.action,
|
|
39
|
+
entry.module,
|
|
40
|
+
entry.version ?? null,
|
|
41
|
+
entry.target ?? null,
|
|
42
|
+
entry.detail == null ? null : JSON.stringify(entry.detail),
|
|
43
|
+
entry.level ?? 6,
|
|
44
|
+
],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** fire-and-forget:审计失败静默(写日志也失败的极端场景下,业务正确性优先)。 */
|
|
49
|
+
export function auditOp(entry: OpEntry): void {
|
|
50
|
+
recordOp(entry).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DENY_WINDOW_MS = 60_000;
|
|
54
|
+
// 串行队列:把同请求内多次 auditDeny 的「查锚点行→更新/插入」串行化(AC1-29 同窗口只许一行首条)。
|
|
55
|
+
// 跨请求并发首条可能落两行(锚点查询非原子)——人去重后并发 403 同 (user,code) 极罕见,可容忍。
|
|
56
|
+
// ponytail: 模块级队列在 oj 每请求重建语义下只管得住单请求内,跨请求靠 DB 行自身作锚。
|
|
57
|
+
let denyQueue: Promise<void> = Promise.resolve();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 403 失败审计入口(判权出口调用):(user_id, code) 分钟窗口聚合,首条 + 计数。
|
|
61
|
+
* uid 在调用瞬间同步捕获——http.user 是可变全局,异步队列执行时再读会被后续请求覆盖。
|
|
62
|
+
*/
|
|
63
|
+
export function auditDeny(code: string): void {
|
|
64
|
+
const uid = http.user?.id ?? 0;
|
|
65
|
+
denyQueue = denyQueue.then(() => recordDeny(uid, code)).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 清窗口状态:窗口锚点即 sys_oplog 行本身——测试夹具/运维直接清表即重置,本函数仅为兼容既有调用面。 */
|
|
69
|
+
export function resetDenyAudit(): void {
|
|
70
|
+
// no-op(状态在 DB;详见文件头反常识修正)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function recordDeny(uid: number, code: string): Promise<void> {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
// 窗口锚点 = 分钟内最近一条同 (user, code) 的 auth.deny 行;命中只更新计数,否则落首条。
|
|
76
|
+
const rows = await db.query(
|
|
77
|
+
"select id, detail from sys_oplog where user_id = ? and action = 'auth.deny' and target = ? and create_time > ? order by id desc limit 1",
|
|
78
|
+
[uid, code, now - DENY_WINDOW_MS],
|
|
79
|
+
);
|
|
80
|
+
const hit = rows[0] as { id: number, detail: string } | undefined;
|
|
81
|
+
if (hit) {
|
|
82
|
+
const count = (JSON.parse(hit.detail) as { count: number }).count + 1;
|
|
83
|
+
await db.exec("update sys_oplog set detail = ? where id = ?", [JSON.stringify({ code, count }), hit.id]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await db.exec(
|
|
87
|
+
"insert into sys_oplog (create_time, user_id, username, action, module, target, detail, level) values (?, ?, ?, 'auth.deny', ?, ?, ?, 4)",
|
|
88
|
+
[now, uid, await usernameOf(uid), moduleOf(code), code, JSON.stringify({ code, count: 1 })],
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 通讯录读模型(D9:users 即通讯录;AC1-22~25)。
|
|
2
|
+
// 目录红线:只出 id/username/display_name——不含 email/avatar/roles(防低权账号拖走全员邮箱喂钓鱼,安全评审 P2);
|
|
3
|
+
// 详情端点才给 email/avatar。默认排除 is_forbidden=1(AC1-23:管理域仍可见,目录与管理分离)。
|
|
4
|
+
// keyword≥2、LIKE 转义复用 query.ts;solutions 同 isolate import searchContacts 直调,无 HTTP 往返(AC1-25)。
|
|
5
|
+
import { likePattern } from "./query";
|
|
6
|
+
|
|
7
|
+
export interface ContactRow {
|
|
8
|
+
id: number
|
|
9
|
+
username: string
|
|
10
|
+
display_name: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LIMIT = 200;
|
|
14
|
+
const MAX_LIMIT = 200;
|
|
15
|
+
|
|
16
|
+
export async function searchContacts(keyword: unknown, opts: { limit?: unknown } = {}): Promise<ContactRow[]> {
|
|
17
|
+
const pattern = likePattern(keyword); // <2 字符在此抛 400(AC1-22)
|
|
18
|
+
const rawLimit = Number.parseInt(String(opts.limit ?? ""), 10);
|
|
19
|
+
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(MAX_LIMIT, rawLimit) : DEFAULT_LIMIT;
|
|
20
|
+
return await db.query(
|
|
21
|
+
`select id, username, display_name from users
|
|
22
|
+
where is_forbidden = 0 and (username like ? escape '!' or display_name like ? escape '!')
|
|
23
|
+
order by username limit ?`,
|
|
24
|
+
[pattern, pattern, limit],
|
|
25
|
+
) as ContactRow[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function contactDetail(id: number): Promise<Record<string, unknown>> {
|
|
29
|
+
const rows = await db.query(
|
|
30
|
+
"select id, username, display_name, email, avatar_base64 from users where id = ? and is_forbidden = 0",
|
|
31
|
+
[id],
|
|
32
|
+
);
|
|
33
|
+
if (!rows[0])
|
|
34
|
+
throw Object.assign(new Error("contact not found"), { status: 404 });
|
|
35
|
+
return rows[0] as Record<string, unknown>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 判权领域核心(DDD 领域服务):四级链的纯函数决策——IO(DB 加载/缓存失效)在应用层
|
|
2
|
+
// (阶段 A3),本层不感知,天然可测。decideAccess 的输入即 AC1-1~6 的判定矩阵。
|
|
3
|
+
//
|
|
4
|
+
// 通过条件任一命中:
|
|
5
|
+
// 1. 持 superadmin → 通过一切 code
|
|
6
|
+
// 2. 持 admin → 通过一切 code
|
|
7
|
+
// 3. 持 <code 首段>.admin → 通过该模块全部 code(零配置推导)
|
|
8
|
+
// 4. 持角色的绑定精确命中 code(含 deprecated——判权不过滤,设计文档 §5)
|
|
9
|
+
|
|
10
|
+
/** code 首段 = 模块名(推导锚定 manifest.name 而非 api.prefix,设计文档 §4A.1 细则 1)。 */
|
|
11
|
+
export function moduleOf(code: string): string {
|
|
12
|
+
const i = code.indexOf(".");
|
|
13
|
+
return i === -1 ? code : code.slice(0, i);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AccessInput {
|
|
17
|
+
/** 用户持有角色(sys_user_role 权威源的投影) */
|
|
18
|
+
roles: string[]
|
|
19
|
+
/** 角色 → 已绑定权限点(sys_role_permission 投影;deprecated 不过滤) */
|
|
20
|
+
bindings: Record<string, string[]>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function decideAccess(input: AccessInput, code: string): boolean {
|
|
24
|
+
const { roles, bindings } = input;
|
|
25
|
+
if (roles.includes("superadmin") || roles.includes("admin"))
|
|
26
|
+
return true;
|
|
27
|
+
if (roles.includes(`${moduleOf(code)}.admin`))
|
|
28
|
+
return true;
|
|
29
|
+
return roles.some(r => (bindings[r] ?? []).includes(code));
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// 操作日志查询(AC1-29/D13:admin 可查;读操作不记审计的红线不适用——本表本身就是审计)。
|
|
2
|
+
// 过滤维度:action 精确 / username 前缀 / module 精确;分页复用 query.ts(limit 钳 200/truncated)。
|
|
3
|
+
import { likePattern, paged } from "./query";
|
|
4
|
+
|
|
5
|
+
export interface OplogRow {
|
|
6
|
+
id: number
|
|
7
|
+
create_time: number
|
|
8
|
+
user_id: number
|
|
9
|
+
username: string
|
|
10
|
+
action: string
|
|
11
|
+
module: string
|
|
12
|
+
version: string | null
|
|
13
|
+
target: string | null
|
|
14
|
+
detail: string | null
|
|
15
|
+
level: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OplogPage {
|
|
19
|
+
list: OplogRow[]
|
|
20
|
+
total: number
|
|
21
|
+
truncated: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function searchOplog(filter: { action?: unknown, username?: unknown, module?: unknown, limit?: unknown }): Promise<OplogPage> {
|
|
25
|
+
const conds: string[] = [];
|
|
26
|
+
const params: unknown[] = [];
|
|
27
|
+
if (filter.action) {
|
|
28
|
+
conds.push("action = ?");
|
|
29
|
+
params.push(String(filter.action));
|
|
30
|
+
}
|
|
31
|
+
if (filter.module) {
|
|
32
|
+
conds.push("module = ?");
|
|
33
|
+
params.push(String(filter.module));
|
|
34
|
+
}
|
|
35
|
+
if (filter.username) {
|
|
36
|
+
conds.push("username like ? escape '!'");
|
|
37
|
+
params.push(likePattern(filter.username));
|
|
38
|
+
}
|
|
39
|
+
const where = conds.length ? `where ${conds.join(" and ")}` : "";
|
|
40
|
+
const r = await paged<OplogRow>(
|
|
41
|
+
`select * from sys_oplog ${where} order by id desc`,
|
|
42
|
+
`select count(*) as c from sys_oplog ${where}`,
|
|
43
|
+
params,
|
|
44
|
+
{ size: filter.limit },
|
|
45
|
+
);
|
|
46
|
+
return { list: r.list, total: r.total, truncated: r.truncated };
|
|
47
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 查询工具(DRY 收敛,AC1-22/24):全部列表接口复用——
|
|
2
|
+
// paged:limit 钳 200(静默)、truncated 标记(offset+list.length < total)、count+list 双查询;
|
|
3
|
+
// likePattern:keyword 长度红线(默认 2~64,越界 400)、LIKE 元字符转义(ESCAPE '!' 三方言同语义)。
|
|
4
|
+
// 错误约定与判权出口一致:Error & { status }。
|
|
5
|
+
|
|
6
|
+
export interface PageResult<T> {
|
|
7
|
+
list: T[]
|
|
8
|
+
total: number
|
|
9
|
+
page: number
|
|
10
|
+
size: number
|
|
11
|
+
/** 还有未返回数据(AC1-24:多取一条判截断) */
|
|
12
|
+
truncated: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_SIZE = 200;
|
|
16
|
+
const DEFAULT_SIZE = 20;
|
|
17
|
+
|
|
18
|
+
function pageArgs(input: { page?: unknown, size?: unknown }): { page: number, size: number, offset: number } {
|
|
19
|
+
const rawPage = Number.parseInt(String(input.page ?? ""), 10);
|
|
20
|
+
const rawSize = Number.parseInt(String(input.size ?? ""), 10);
|
|
21
|
+
const page = Number.isFinite(rawPage) && rawPage >= 1 ? rawPage : 1;
|
|
22
|
+
// 非法/<1 回落默认 20,再钳上限 200(AC1-24 静默钳制)
|
|
23
|
+
const size = Math.min(MAX_SIZE, Number.isFinite(rawSize) && rawSize >= 1 ? rawSize : DEFAULT_SIZE);
|
|
24
|
+
return { page, size, offset: (page - 1) * size };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 双查询分页:listSql/countSql 共用 params(调用方同一 WHERE),limit/offset 参数化(三方言安全)。 */
|
|
28
|
+
export async function paged<T>(
|
|
29
|
+
listSql: string,
|
|
30
|
+
countSql: string,
|
|
31
|
+
params: unknown[],
|
|
32
|
+
input: { page?: unknown, size?: unknown },
|
|
33
|
+
): Promise<PageResult<T>> {
|
|
34
|
+
const { page, size, offset } = pageArgs(input);
|
|
35
|
+
const total = Number((await db.query(countSql, params) as Array<Record<string, unknown>>)[0]?.c ?? 0);
|
|
36
|
+
const list = await db.query(`${listSql} limit ? offset ?`, [...params, size, offset]) as T[];
|
|
37
|
+
return { list, total, page, size, truncated: offset + list.length < total };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* LIKE 模式:修剪后校验长度(默认 2~64),元字符 % _ ! 加 ! 转义,两侧包 %。
|
|
42
|
+
* SQL 侧必须写 `like ? escape '!'`(三方言同语义;sqlite 默认无转义符,mysql/pg 默认不是 !)。
|
|
43
|
+
*/
|
|
44
|
+
export function likePattern(keyword: unknown, opts: { minLen?: number, maxLen?: number } = {}): string {
|
|
45
|
+
const kw = String(keyword ?? "").trim();
|
|
46
|
+
const min = opts.minLen ?? 2;
|
|
47
|
+
const max = opts.maxLen ?? 64;
|
|
48
|
+
if (kw.length < min)
|
|
49
|
+
throw Object.assign(new Error(`keyword too short (min ${min})`), { status: 400 });
|
|
50
|
+
if (kw.length > max)
|
|
51
|
+
throw Object.assign(new Error(`keyword too long (max ${max})`), { status: 400 });
|
|
52
|
+
return `%${kw.replace(/[!%_]/g, m => `!${m}`)}%`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 限速出口(B5/AC1-26 + 接口级限速)。反常识修正:oj 运行时每请求重建模块实例,
|
|
2
|
+
// 进程内 Map 不跨请求存活(实测模块级计数器恒为初值)——状态全部落库:
|
|
3
|
+
// sys_rate_window(滑动窗口计数)/ sys_login_throttle(连败 + 指数 backoff)。
|
|
4
|
+
// 落库后重启保留、多实例天然同源(原先「多实例换 Redis」的已知上限就此消除)。
|
|
5
|
+
// ponytail: SELECT→DELETE+INSERT/UPDATE 两步写,高并发下窗口计数可能丢几拍——限速场景可容忍。
|
|
6
|
+
|
|
7
|
+
export interface RateLimitOpts {
|
|
8
|
+
/** 时钟注入(测试用);缺省 Date.now() */
|
|
9
|
+
now?: () => number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** key 维度(如 `contacts:<uid>` / `login:ip:<ip>`):窗口内超 limit → 429。 */
|
|
13
|
+
export async function rateLimit(key: string, limit: number, windowMs: number, opts: RateLimitOpts = {}): Promise<void> {
|
|
14
|
+
const now = opts.now?.() ?? Date.now();
|
|
15
|
+
const rows = await db.query("select window_start, count from sys_rate_window where rate_key = ?", [key]);
|
|
16
|
+
const w = rows[0] as { window_start: number, count: number } | undefined;
|
|
17
|
+
if (!w || now - Number(w.window_start) >= windowMs) {
|
|
18
|
+
await db.exec("delete from sys_rate_window where rate_key = ?", [key]);
|
|
19
|
+
await db.exec("insert into sys_rate_window (rate_key, window_start, count) values (?, ?, 1)", [key, now]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const count = Number(w.count) + 1;
|
|
23
|
+
await db.exec("update sys_rate_window set count = ? where rate_key = ?", [count, key]);
|
|
24
|
+
if (count > limit)
|
|
25
|
+
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---- 登录限速(AC1-26):(IP, username) 连败阈值 → 429 + 指数 backoff(状态在 sys_login_throttle)----
|
|
29
|
+
|
|
30
|
+
export interface LoginThrottleOpts extends RateLimitOpts {
|
|
31
|
+
/** 连败阈值(默认 6,AC1-26) */
|
|
32
|
+
threshold?: number
|
|
33
|
+
/** 基础 backoff(默认 15s);第 n 次超阈失败阻断 base × 2^(n - threshold) */
|
|
34
|
+
baseBackoffMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LoginThrottleRow {
|
|
38
|
+
fail_count: number
|
|
39
|
+
blocked_until: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 登录失败计数 +1;达到阈值即按指数 backoff 设阻断窗口。返回累计连败数。 */
|
|
43
|
+
export async function loginFailed(key: string, opts: LoginThrottleOpts = {}): Promise<number> {
|
|
44
|
+
const threshold = opts.threshold ?? 6;
|
|
45
|
+
const base = opts.baseBackoffMs ?? 15_000;
|
|
46
|
+
const now = opts.now?.() ?? Date.now();
|
|
47
|
+
const rows = await db.query("select fail_count, blocked_until from sys_login_throttle where pair_key = ?", [key]);
|
|
48
|
+
const s = rows[0] as LoginThrottleRow | undefined;
|
|
49
|
+
const count = (s ? Number(s.fail_count) : 0) + 1;
|
|
50
|
+
const blockedUntil = count >= threshold ? now + base * 2 ** (count - threshold) : (s ? Number(s.blocked_until) : 0);
|
|
51
|
+
await db.exec("delete from sys_login_throttle where pair_key = ?", [key]);
|
|
52
|
+
await db.exec("insert into sys_login_throttle (pair_key, fail_count, blocked_until) values (?, ?, ?)", [key, count, blockedUntil]);
|
|
53
|
+
return count;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 登录前检查:阻断期内抛 429;且每次尝试都累加失败并指数延长(防阻断期无脑重试)。 */
|
|
57
|
+
export async function loginBlocked(key: string, opts: LoginThrottleOpts = {}): Promise<void> {
|
|
58
|
+
const threshold = opts.threshold ?? 6;
|
|
59
|
+
const base = opts.baseBackoffMs ?? 15_000;
|
|
60
|
+
const now = opts.now?.() ?? Date.now();
|
|
61
|
+
const rows = await db.query("select fail_count, blocked_until from sys_login_throttle where pair_key = ?", [key]);
|
|
62
|
+
const s = rows[0] as LoginThrottleRow | undefined;
|
|
63
|
+
if (!s || Number(s.blocked_until) <= now)
|
|
64
|
+
return;
|
|
65
|
+
const count = Number(s.fail_count) + 1;
|
|
66
|
+
const blockedUntil = now + base * 2 ** (count - threshold);
|
|
67
|
+
await db.exec("update sys_login_throttle set fail_count = ?, blocked_until = ? where pair_key = ?", [count, blockedUntil, key]);
|
|
68
|
+
throw Object.assign(new Error("too many login attempts"), { status: 429, retryAfterMs: blockedUntil - now });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 登录成功清零连败。 */
|
|
72
|
+
export async function loginSucceeded(key: string): Promise<void> {
|
|
73
|
+
await db.exec("delete from sys_login_throttle where pair_key = ?", [key]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 清登录限速状态(测试夹具/运维排障用)。 */
|
|
77
|
+
export async function resetLoginThrottle(): Promise<void> {
|
|
78
|
+
await db.exec("delete from sys_login_throttle", []);
|
|
79
|
+
}
|