@oj-module/cli 0.1.21 → 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.
Files changed (107) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +17 -3
  3. package/devkit/manual.md +77 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
  6. package/shell-dist/assets/runtime.js +226 -89
  7. package/shell-dist/index.html +3 -3
  8. package/shell-dist/versions.json +3 -3
  9. package/src/build.ts +25 -0
  10. package/src/init.ts +3 -0
  11. package/templates/README.md +15 -0
  12. package/templates/api/config.yaml +14 -0
  13. package/templates/api/seed.mysql.sql +5 -0
  14. package/templates/api/seed.pg.sql +5 -0
  15. package/templates/api/src/_platform/_shared/respond.ts +18 -0
  16. package/templates/api/src/_platform/_shared/service/access.ts +99 -0
  17. package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
  18. package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
  19. package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
  20. package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
  21. package/templates/api/src/_platform/_shared/service/query.ts +53 -0
  22. package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
  23. package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
  24. package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
  25. package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
  26. package/templates/api/src/_platform/_shared/service/users.ts +163 -0
  27. package/templates/api/src/_platform/contacts/api.ts +22 -0
  28. package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
  29. package/templates/api/src/_platform/manifest.yaml +9 -1
  30. package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
  31. package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
  32. package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
  33. package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
  34. package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
  35. package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
  36. package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
  37. package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
  38. package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
  39. package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
  40. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
  41. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
  42. package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
  43. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
  44. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
  45. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
  46. package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
  47. package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
  48. package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
  49. package/templates/api/src/_platform/schema.yaml +76 -1
  50. package/templates/api/src/_platform/seed.mysql.sql +8 -0
  51. package/templates/api/src/_platform/seed.pg.sql +8 -0
  52. package/templates/api/src/_platform/seed.sql +19 -2
  53. package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
  54. package/templates/api/src/auth/_shared/ldap.ts +14 -0
  55. package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
  56. package/templates/api/src/auth/_shared/session.ts +163 -18
  57. package/templates/api/src/auth/api.ts +54 -44
  58. package/templates/api/src/auth/ldap-login/api.ts +29 -0
  59. package/templates/api/src/system/login-methods/api.ts +19 -0
  60. package/templates/api/src/system/manifest.yaml +5 -0
  61. package/templates/api/src/system/oplog/api.ts +53 -0
  62. package/templates/api/src/system/permissions/api.ts +26 -0
  63. package/templates/api/src/system/roles/api.ts +75 -0
  64. package/templates/api/src/system/roles/bindings/api.ts +29 -0
  65. package/templates/api/src/system/settings/api.ts +60 -0
  66. package/templates/api/src/system/users/api.ts +58 -0
  67. package/templates/api/src/system/users/forbidden/api.ts +26 -0
  68. package/templates/api/src/system/users/password/api.ts +26 -0
  69. package/templates/api/src/system/users/roles/api.ts +27 -0
  70. package/templates/api/src/web/user-info/api.ts +9 -18
  71. package/templates/api/tests/auth-ldap.spec.ts +173 -0
  72. package/templates/api/tests/auth-session.spec.ts +194 -0
  73. package/templates/api/tests/auth-throttle.spec.ts +74 -0
  74. package/templates/api/tests/fixture.ts +50 -0
  75. package/templates/api/tests/guard.spec.ts +62 -0
  76. package/templates/api/tests/query-utils.spec.ts +68 -0
  77. package/templates/api/tests/rbac-audit.spec.ts +105 -0
  78. package/templates/api/tests/rbac-contacts.spec.ts +85 -0
  79. package/templates/api/tests/rbac-guard.spec.ts +192 -0
  80. package/templates/api/tests/rbac-roles.spec.ts +159 -0
  81. package/templates/api/tests/rbac-seed.spec.ts +54 -0
  82. package/templates/api/tests/rbac-settings.spec.ts +79 -0
  83. package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
  84. package/templates/api/tests/rbac-tables.spec.ts +63 -0
  85. package/templates/api/tests/rbac-users.spec.ts +164 -0
  86. package/templates/api/vitest.config.ts +12 -0
  87. package/templates/openapi.json +1060 -31
  88. package/templates/pnpm-workspace.yaml +9 -0
  89. package/templates/web/src/auth/__client/api.schemas.ts +6 -1
  90. package/templates/web/src/auth/__client/api.ts +33 -1
  91. package/templates/web/src/login/entry.ts +17 -0
  92. package/templates/web/src/login/locales/en-US.json +12 -2
  93. package/templates/web/src/login/locales/zh-CN.json +12 -2
  94. package/templates/web/src/login/pages/change-password.tsx +74 -0
  95. package/templates/web/src/system/entry.ts +90 -0
  96. package/templates/web/src/system/locales/en-US.json +98 -0
  97. package/templates/web/src/system/locales/zh-CN.json +98 -0
  98. package/templates/web/src/system/pages/errors.ts +51 -0
  99. package/templates/web/src/system/pages/forbidden.tsx +35 -0
  100. package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
  101. package/templates/web/src/system/pages/oplog/index.tsx +91 -0
  102. package/templates/web/src/system/pages/roles/index.tsx +91 -0
  103. package/templates/web/src/system/pages/settings/index.tsx +106 -0
  104. package/templates/web/src/system/pages/users/index.tsx +168 -0
  105. package/templates/web/src/system/pages/users/roles.tsx +68 -0
  106. package/templates/web.config.ts +8 -0
  107. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,137 @@
1
+ // 角色管理应用服务(管理域 /api/system/roles,AC1-15~20/28/30)。
2
+ // 规则锚:custom 机器名强制(AC1-17)、builtin 角色绑定/删除硬拒(AC1-19)、占用禁删(AC1-20)、
3
+ // 绑定调整 = 紧急吊销出口(AC1-30,写后按持有者失效判权缓存)、写审计 await recordOp(AC1-28 关键路径)。
4
+ // 判权本身不经过这里——调用 handler 先过 assertSystemAdmin(H2,管理类端点)。
5
+ import { invalidateUser } from "./access";
6
+ import { recordOp } from "./audit";
7
+
8
+ export interface RoleRow {
9
+ name: string
10
+ module: string
11
+ title: string | null
12
+ description: string | null
13
+ builtin: boolean
14
+ deprecated: boolean
15
+ /** 持有者人数(占用禁删判据) */
16
+ holders: number
17
+ /** 已绑定权限点(含 deprecated——判权不过滤,标灰是 UI 职责) */
18
+ permissions: string[]
19
+ }
20
+
21
+ export interface PermissionRow {
22
+ code: string
23
+ module: string
24
+ title_key: string | null
25
+ deprecated: boolean
26
+ }
27
+
28
+ function fail(status: number, message: string, extra?: Record<string, unknown>): never {
29
+ throw Object.assign(new Error(message), { status }, extra);
30
+ }
31
+
32
+ /** custom 角色机器名:custom.<slug>,slug 小写字母数字连字符(AC1-17 命名空间强制)。 */
33
+ const CUSTOM_NAME = /^custom\.[a-z0-9][a-z0-9-]*$/;
34
+ /** 框架保留名(solutions 命名空间 <name>.* 天然被 custom. 前缀排除) */
35
+ const RESERVED = new Set(["superadmin", "admin", "editor", "viewer"]);
36
+
37
+ async function assertNotBuiltin(roleName: string): Promise<void> {
38
+ const rows = await db.query("select builtin from sys_role where name = ?", [roleName]);
39
+ if (!rows[0])
40
+ fail(404, `role not found: ${roleName}`);
41
+ if (Number((rows[0] as { builtin: unknown }).builtin) === 1)
42
+ fail(403, "builtin role is managed by framework");
43
+ }
44
+
45
+ async function assertCodesExist(codes: string[]): Promise<void> {
46
+ if (!codes.length)
47
+ return;
48
+ const marks = codes.map(() => "?").join(",");
49
+ const rows = await db.query(`select code from sys_permission where code in (${marks})`, codes);
50
+ const known = new Set(rows.map((r: { code: string }) => r.code));
51
+ const unknown = codes.filter(c => !known.has(c));
52
+ if (unknown.length)
53
+ fail(400, `unknown permission codes: ${unknown.join(", ")}`);
54
+ }
55
+
56
+ /** 绑定/删除写后按持有者逐人失效判权缓存(D10 撤销即强退的 service 级出口)。 */
57
+ async function invalidateHolders(roleName: string): Promise<void> {
58
+ const rows = await db.query("select user_id from sys_user_role where role_name = ?", [roleName]);
59
+ for (const r of rows as Array<{ user_id: number }>)
60
+ invalidateUser(Number(r.user_id));
61
+ }
62
+
63
+ export async function listRoles(): Promise<RoleRow[]> {
64
+ const roles = await db.query("select * from sys_role order by name") as Array<Record<string, unknown>>;
65
+ const holders = await db.query("select role_name, count(*) as c from sys_user_role group by role_name") as Array<{ role_name: string, c: number }>;
66
+ const binds = await db.query("select role_name, permission_code from sys_role_permission order by permission_code") as Array<{ role_name: string, permission_code: string }>;
67
+ const holderMap = new Map(holders.map(h => [h.role_name, Number(h.c)]));
68
+ const bindMap = new Map<string, string[]>();
69
+ for (const b of binds)
70
+ (bindMap.get(b.role_name) ?? bindMap.set(b.role_name, []).get(b.role_name))!.push(b.permission_code);
71
+ return roles.map(r => ({
72
+ name: String(r.name),
73
+ module: String(r.module),
74
+ title: r.title == null ? null : String(r.title),
75
+ description: r.description == null ? null : String(r.description),
76
+ builtin: Number(r.builtin) === 1,
77
+ deprecated: Number(r.deprecated) === 1,
78
+ holders: holderMap.get(String(r.name)) ?? 0,
79
+ permissions: bindMap.get(String(r.name)) ?? [],
80
+ }));
81
+ }
82
+
83
+ export async function listPermissions(): Promise<PermissionRow[]> {
84
+ const rows = await db.query("select * from sys_permission order by code") as Array<Record<string, unknown>>;
85
+ return rows.map(r => ({
86
+ code: String(r.code),
87
+ module: String(r.module),
88
+ title_key: r.title_key == null ? null : String(r.title_key),
89
+ deprecated: Number(r.deprecated) === 1,
90
+ }));
91
+ }
92
+
93
+ export async function createRole(input: { name: string, title: string, permissions?: string[] }): Promise<{ name: string, module: string }> {
94
+ const name = String(input.name ?? "").trim();
95
+ if (!CUSTOM_NAME.test(name) || RESERVED.has(name))
96
+ fail(400, `role name must be custom.<slug> (reserved/framework/solution namespaces rejected): ${name}`);
97
+ const permissions = [...new Set((input.permissions ?? []).map(String))];
98
+ await assertCodesExist(permissions);
99
+ await db.tx(async (t) => {
100
+ await t.exec("insert into sys_role (name, module, title, builtin, deprecated) values (?, 'custom', ?, 0, 0)", [name, String(input.title ?? "")]);
101
+ for (const code of permissions)
102
+ await t.exec("insert into sys_role_permission (role_name, permission_code) values (?, ?)", [name, code]);
103
+ });
104
+ await recordOp({ action: "role.create", module: "framework", target: name, detail: { title: String(input.title ?? ""), permissions } });
105
+ return { name, module: "custom" };
106
+ }
107
+
108
+ export async function updateBindings(roleName: string, codes: string[]): Promise<void> {
109
+ await assertNotBuiltin(roleName);
110
+ const next = [...new Set(codes.map(String))];
111
+ await assertCodesExist(next);
112
+ const before = (await db.query("select permission_code from sys_role_permission where role_name = ?", [roleName])) as Array<{ permission_code: string }>;
113
+ const beforeSet = new Set(before.map(b => b.permission_code));
114
+ await db.tx(async (t) => {
115
+ await t.exec("delete from sys_role_permission where role_name = ?", [roleName]);
116
+ for (const code of next)
117
+ await t.exec("insert into sys_role_permission (role_name, permission_code) values (?, ?)", [roleName, code]);
118
+ });
119
+ await invalidateHolders(roleName);
120
+ const added = next.filter(c => !beforeSet.has(c));
121
+ const removed = [...beforeSet].filter(c => !next.includes(c));
122
+ await recordOp({ action: "role.bindings.update", module: "framework", target: roleName, detail: { added, removed } });
123
+ }
124
+
125
+ export async function deleteRole(roleName: string): Promise<void> {
126
+ await assertNotBuiltin(roleName);
127
+ const holders = await db.query("select count(*) as c from sys_user_role where role_name = ?", [roleName]);
128
+ const n = Number((holders[0] as { c: unknown }).c);
129
+ if (n > 0)
130
+ fail(409, `role has ${n} holder(s)`, { holders: n });
131
+ await db.tx(async (t) => {
132
+ await t.exec("delete from sys_role_permission where role_name = ?", [roleName]);
133
+ await t.exec("delete from sys_role where name = ?", [roleName]);
134
+ });
135
+ await invalidateHolders(roleName);
136
+ await recordOp({ action: "role.delete", module: "framework", target: roleName });
137
+ }
@@ -0,0 +1,67 @@
1
+ // 系统设置(PRD §4.3)+ 登录方式发现(C5/D2)。
2
+ // secret 项读取掩码(***);market.* 安全项写操作须 superadmin 操作者(D11 边界在 service 按 actor 角色查)。
3
+ import { recordOp } from "./audit";
4
+
5
+ export interface SettingItem {
6
+ key: string
7
+ module: string
8
+ group: string
9
+ type: string
10
+ /** secret 项恒为 "***"(读取面掩码) */
11
+ value: string | null
12
+ default_value: string | null
13
+ label_key: string | null
14
+ options: string | null
15
+ secret: boolean
16
+ update_time: number
17
+ }
18
+
19
+ export interface SettingGroup {
20
+ group: string
21
+ items: SettingItem[]
22
+ }
23
+
24
+ /** 安全敏感组:写操作须 superadmin(D11:不建模为权限点,物理判断)。 */
25
+ const SUPER_ONLY_GROUPS = new Set(["security"]);
26
+
27
+ export async function listSettings(): Promise<SettingGroup[]> {
28
+ const rows = await db.query("select * from sys_setting order by \"group\", key") as Array<Record<string, unknown>>;
29
+ const groups = new Map<string, SettingItem[]>();
30
+ for (const r of rows) {
31
+ const item: SettingItem = {
32
+ key: String(r.key),
33
+ module: String(r.module),
34
+ group: String(r.group),
35
+ type: String(r.type),
36
+ value: Number(r.secret) === 1 ? "***" : (r.value == null ? null : String(r.value)),
37
+ default_value: r.default_value == null ? null : String(r.default_value),
38
+ label_key: r.label_key == null ? null : String(r.label_key),
39
+ options: r.options == null ? null : String(r.options),
40
+ secret: Number(r.secret) === 1,
41
+ update_time: Number(r.update_time),
42
+ };
43
+ (groups.get(item.group) ?? groups.set(item.group, []).get(item.group))!.push(item);
44
+ }
45
+ return [...groups.entries()].map(([group, items]) => ({ group, items }));
46
+ }
47
+
48
+ export async function updateSetting(actorId: number, key: string, value: string): Promise<void> {
49
+ const rows = await db.query("select * from sys_setting where key = ?", [key]) as Array<Record<string, unknown>>;
50
+ if (!rows.length)
51
+ throw Object.assign(new Error(`setting not found: ${key}`), { status: 404 });
52
+ const row = rows[0];
53
+ if (SUPER_ONLY_GROUPS.has(String(row.group))) {
54
+ const actorRoles = await db.query("select role_name from sys_user_role where user_id = ?", [actorId]) as Array<{ role_name: string }>;
55
+ if (!actorRoles.some(r => r.role_name === "superadmin"))
56
+ throw Object.assign(new Error("security settings require superadmin"), { status: 403 });
57
+ }
58
+ await db.exec("update sys_setting set value = ?, update_time = ? where key = ?", [String(value ?? ""), Date.now(), key]);
59
+ await recordOp({ action: "setting.update", module: String(row.module), target: key });
60
+ }
61
+
62
+ /** C5/D2:登录方式发现的数据源——local 恒在,ldap/sso 由设置项开关。 */
63
+ export async function listLoginMethods(): Promise<{ methods: string[] }> {
64
+ const rows = await db.query("select key, value from sys_setting where key in ('ldap.enabled', 'sso.enabled')") as Array<{ key: string, value: string | null }>;
65
+ const enabled = (k: string) => rows.find(r => r.key === k)?.value === "1";
66
+ return { methods: ["local", ...(enabled("ldap.enabled") ? ["ldap" as const] : []), ...(enabled("sso.enabled") ? ["sso" as const] : [])] };
67
+ }
@@ -0,0 +1,94 @@
1
+ // 解决方案权限声明的升级/卸载规则出口(设计 §5「减法唯一出口 = 卸载」;AC2-1~6 / AC1-14/15)。
2
+ // 安装器本体属 PRD M5;本出口即 M5 之前钉死规则的承载面——安装器唯一要遵守的纪律:
3
+ // 安装 = upsertSolutionPerms(只做加法 + 元数据覆盖 + 声明消失标 deprecated),
4
+ // 卸载 = uninstallSolution(双维度级联清理);除此之外不得直写 sys_role/sys_permission 两表。
5
+ // deprecated 双向过滤红线(§5):判权链不过滤 deprecated(本文件不做任何判权过滤),
6
+ // 管理 UI 过滤——管理面在 roles service(listPermissions 带标记,前端标灰)。
7
+ export interface SolutionRoleDecl {
8
+ /** 完整角色名(含 `<module>.` 前缀,AC1-11 由安装器侧前缀校验) */
9
+ name: string
10
+ title: string
11
+ /** 声明绑定:insert-if-absent 写入,管理员调整永不被覆写(AC1-15) */
12
+ permissions?: string[]
13
+ }
14
+
15
+ export interface SolutionManifest {
16
+ module: string
17
+ roles: SolutionRoleDecl[]
18
+ }
19
+
20
+ export interface UpsertResult {
21
+ /** 本次被标 deprecated 的 code/name 清单——安装器据此强告警「N 个权限点需重新分配」(AC2-4) */
22
+ deprecated: string[]
23
+ }
24
+
25
+ export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertResult> {
26
+ const deprecated: string[] = [];
27
+ const declaredRoles = new Set(m.roles.map(r => r.name));
28
+ const declaredPerms = new Set(m.roles.flatMap(r => r.permissions ?? []));
29
+
30
+ for (const role of m.roles) {
31
+ const existing = await db.query("select name from sys_role where name = ?", [role.name]) as unknown[];
32
+ // upsert:行存在只覆盖元数据(title),name/builtin 不动(AC2-2 元数据覆盖)
33
+ if (existing.length) {
34
+ await db.exec("update sys_role set title = ? where name = ?", [role.title, role.name]);
35
+ }
36
+ else {
37
+ await db.exec("insert into sys_role (name, module, title, builtin) values (?, ?, ?, 0)", [role.name, m.module, role.title]);
38
+ // 声明绑定只在角色创建时写一次(角色级 insert-if-absent,AC1-15 红线):
39
+ // 升级/重装对已存在角色永不补写声明绑定——管理员撤过的绑定不复活;
40
+ // 新版本给既有角色新增权限点时,由管理员显式勾选(insert-if-absent 的取舍边界,§9.5)。
41
+ for (const code of role.permissions ?? []) {
42
+ const bound = await db.query("select role_name from sys_role_permission where role_name = ? and permission_code = ?", [role.name, code]) as unknown[];
43
+ if (!bound.length)
44
+ await db.exec("insert into sys_role_permission (role_name, permission_code) values (?, ?)", [role.name, code]);
45
+ }
46
+ }
47
+ }
48
+
49
+ // 权限点行独立于角色创建:声明里的新 code 一律 upsert 插入(AC2-1「新增声明 → upsert 插入新行」——
50
+ // 含既有角色新增权限点的场景;绑定是否自动挂上由上面的角色级规则决定)
51
+ for (const code of declaredPerms) {
52
+ const p = await db.query("select code from sys_permission where code = ?", [code]) as unknown[];
53
+ if (!p.length)
54
+ await db.exec("insert into sys_permission (code, module) values (?, ?)", [code, m.module]);
55
+ }
56
+
57
+ // 声明消失 → 行保留标 deprecated(AC2-3/4):行与绑定全留,判决不突变;
58
+ // 重命名的旧 code 走同一条规则(视为删除+新增),deprecated 清单交给安装器告警。
59
+ const oldPerms = await db.query("select code from sys_permission where module = ? and deprecated = 0", [m.module]) as Array<{ code: string }>;
60
+ for (const r of oldPerms) {
61
+ if (!declaredPerms.has(String(r.code))) {
62
+ await db.exec("update sys_permission set deprecated = 1 where code = ?", [r.code]);
63
+ deprecated.push(String(r.code));
64
+ }
65
+ }
66
+ const oldRoles = await db.query("select name from sys_role where module = ? and builtin = 0 and deprecated = 0", [m.module]) as Array<{ name: string }>;
67
+ for (const r of oldRoles) {
68
+ if (!declaredRoles.has(String(r.name))) {
69
+ await db.exec("update sys_role set deprecated = 1 where name = ?", [r.name]);
70
+ deprecated.push(String(r.name));
71
+ }
72
+ }
73
+ return { deprecated };
74
+ }
75
+
76
+ /**
77
+ * 卸载级联清理(AC2-6,唯一减法出口)——双维度:
78
+ * ① 该 module 的角色行 + 这些角色的全部绑定 + 用户成员资格;
79
+ * ② permission_code 属该 module 的绑定(含 custom 角色上的)+ 权限点行——
80
+ * 漏维度②会让 custom 角色残留绑定在重装时静默复活旧授权。custom 角色本体保留(空绑定)。
81
+ * AC2-7:业务表不属本出口,运维手工处理(Q4)。
82
+ */
83
+ export async function uninstallSolution(module: string): Promise<void> {
84
+ const roles = await db.query("select name from sys_role where module = ?", [module]) as Array<{ name: string }>;
85
+ for (const r of roles) {
86
+ await db.exec("delete from sys_role_permission where role_name = ?", [r.name]);
87
+ await db.exec("delete from sys_user_role where role_name = ?", [r.name]);
88
+ }
89
+ await db.exec("delete from sys_role where module = ?", [module]);
90
+ // module 名是 slug([a-z0-9_-]),LIKE 前缀匹配的转义纪律同 query.ts(ESCAPE '!' 三方言同义)
91
+ const prefix = `${module.replace(/[!%_]/g, c => `!${c}`)}.%`;
92
+ await db.exec("delete from sys_role_permission where permission_code like ? escape '!'", [prefix]);
93
+ await db.exec("delete from sys_permission where module = ?", [module]);
94
+ }
@@ -0,0 +1,163 @@
1
+ // 账号管理应用服务(管理域 /api/system/users,AC1-7~10 + B6 建号加固)。
2
+ // 红线:admin 不碰 superadmin/admin 成员资格(AC1-8,service 按 actor 角色自查);
3
+ // 禁止操作自己(AC1-10 注);最后 superadmin 兜底(服务端二次防线);
4
+ // 角色唯一权威源 sys_user_role + 写时失效(AC1-7/D10);禁用/重置口令级联删 refresh(§3.1)。
5
+ // bcrypt 经注入(Node 侧全局不可 stub,同 session.ts 约定)。
6
+ import { invalidateUser } from "./access";
7
+ import { recordOp } from "./audit";
8
+
9
+ export interface UserDeps {
10
+ bcryptHash: (pw: string) => Promise<string>
11
+ }
12
+
13
+ export interface UserRow {
14
+ id: number
15
+ username: string
16
+ display_name: string | null
17
+ email: string | null
18
+ is_forbidden: boolean
19
+ must_change: boolean
20
+ roles: string[]
21
+ }
22
+
23
+ function fail(status: number, message: string): never {
24
+ throw Object.assign(new Error(message), { status });
25
+ }
26
+
27
+ /** 外来号命名空间抢占红线(B6):本地建号拒收 ldap:/sso: 前缀用户名。 */
28
+ const RESERVED_PREFIX = /^(?:ldap|sso):/i;
29
+
30
+ async function rolesOf(uid: number): Promise<string[]> {
31
+ const rows = await db.query("select role_name from sys_user_role where user_id = ?", [uid]) as Array<{ role_name: string }>;
32
+ return rows.map(r => r.role_name);
33
+ }
34
+
35
+ async function usernameOf(uid: number): Promise<string> {
36
+ const rows = await db.query("select username from users where id = ?", [uid]) as Array<{ username: string }>;
37
+ return rows[0] ? String(rows[0].username) : `#${uid}`;
38
+ }
39
+
40
+ function assertNotSelf(actorId: number, targetUid: number): void {
41
+ if (Number(actorId) === Number(targetUid))
42
+ fail(400, "cannot operate on yourself");
43
+ }
44
+
45
+ /** AC1-8:成员资格(superadmin/admin 分配)仅 superadmin 可动。 */
46
+ async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Promise<void> {
47
+ if (!nextRoles.includes("superadmin") && !nextRoles.includes("admin"))
48
+ return;
49
+ const actorRoles = await rolesOf(Number(actorId));
50
+ if (!actorRoles.includes("superadmin"))
51
+ fail(403, "membership management requires superadmin");
52
+ }
53
+
54
+ export async function listUsers(): Promise<UserRow[]> {
55
+ const rows = await db.query(
56
+ "select id, username, display_name, email, is_forbidden, must_change from users order by username",
57
+ ) as Array<Record<string, unknown>>;
58
+ const roleRows = await db.query("select user_id, role_name from sys_user_role") as Array<{ user_id: number, role_name: string }>;
59
+ const roleMap = new Map<number, string[]>();
60
+ for (const r of roleRows)
61
+ (roleMap.get(Number(r.user_id)) ?? roleMap.set(Number(r.user_id), []).get(Number(r.user_id)))!.push(r.role_name);
62
+ return rows.map(r => ({
63
+ id: Number(r.id),
64
+ username: String(r.username),
65
+ display_name: r.display_name == null ? null : String(r.display_name),
66
+ email: r.email == null ? null : String(r.email),
67
+ is_forbidden: Number(r.is_forbidden) === 1,
68
+ must_change: Number(r.must_change) === 1,
69
+ roles: roleMap.get(Number(r.id)) ?? [],
70
+ }));
71
+ }
72
+
73
+ /** B6:建号——拒外来号前缀、口令 ≥6、默认 must_change=1。返回新用户 id。 */
74
+ export async function createUser(input: { username: string, password: string, displayName?: string, email?: string }, deps: UserDeps): Promise<number> {
75
+ const username = String(input.username ?? "").trim();
76
+ if (!username)
77
+ fail(400, "username required");
78
+ if (RESERVED_PREFIX.test(username))
79
+ fail(400, "username prefix reserved for external identity sources");
80
+ if (!input.password || String(input.password).length < 6)
81
+ fail(400, "password too short (min 6)");
82
+ const exists = await db.query("select id from users where username = ?", [username]);
83
+ if (exists.length)
84
+ fail(400, "username exists");
85
+ const hash = await deps.bcryptHash(String(input.password));
86
+ const rows = await db.query(
87
+ "insert into users (username, password_hash, display_name, email, must_change) values (?, ?, ?, ?, 1) returning id",
88
+ [username, hash, input.displayName ?? null, input.email ?? null],
89
+ ) as Array<{ id: number }>;
90
+ const uid = Number(rows[0].id);
91
+ await recordOp({ action: "user.create", module: "framework", target: username });
92
+ return uid;
93
+ }
94
+
95
+ /** AC1-7:整集替换角色(sys_user_role 权威源),写后失效持有者判权缓存。 */
96
+ export async function updateUserRoles(actorId: number, targetUid: number, roles: string[]): Promise<void> {
97
+ assertNotSelf(actorId, targetUid);
98
+ const target = await db.query("select id from users where id = ?", [targetUid]);
99
+ if (!target.length)
100
+ fail(404, "user not found");
101
+ const next = [...new Set(roles.map(String))];
102
+ if (next.length) {
103
+ const marks = next.map(() => "?").join(",");
104
+ const known = await db.query(`select name from sys_role where name in (${marks})`, next);
105
+ const knownSet = new Set(known.map((r: { name: string }) => r.name));
106
+ const unknown = next.filter(r => !knownSet.has(r));
107
+ if (unknown.length)
108
+ fail(400, `unknown roles: ${unknown.join(", ")}`);
109
+ }
110
+ await assertMembershipAllowed(actorId, next);
111
+ const before = await rolesOf(Number(targetUid));
112
+ const beforeSet = new Set(before);
113
+ // AC1-10 兜底:撤掉最后一个 superadmin 拒绝(现实可达路径已被「禁止操作自己」拦截)
114
+ if (beforeSet.has("superadmin") && !next.includes("superadmin")) {
115
+ const supers = await db.query("select count(*) c from sys_user_role where role_name = 'superadmin'") as Array<{ c: number }>;
116
+ if (Number(supers[0].c) <= 1)
117
+ fail(400, "cannot revoke the last superadmin");
118
+ }
119
+ await db.tx(async (t) => {
120
+ await t.exec("delete from sys_user_role where user_id = ?", [targetUid]);
121
+ for (const role of next)
122
+ await t.exec("insert into sys_user_role (user_id, role_name) values (?, ?)", [targetUid, role]);
123
+ });
124
+ invalidateUser(Number(targetUid));
125
+ await recordOp({
126
+ action: "user.roles.update",
127
+ module: "framework",
128
+ target: await usernameOf(Number(targetUid)),
129
+ detail: { added: next.filter(r => !beforeSet.has(r)), removed: [...beforeSet].filter(r => !next.includes(r)) },
130
+ });
131
+ }
132
+
133
+ /** B7:管理员重置口令——must_change=1 + 级联删 refresh + 写时失效。 */
134
+ export async function changePasswordByAdmin(actorId: number, targetUid: number, newPassword: string, deps: UserDeps): Promise<void> {
135
+ assertNotSelf(actorId, targetUid);
136
+ if (!newPassword || String(newPassword).length < 6)
137
+ fail(400, "password too short (min 6)");
138
+ const rows = await db.query("select id from users where id = ?", [targetUid]);
139
+ if (!rows.length)
140
+ fail(404, "user not found");
141
+ const hash = await deps.bcryptHash(String(newPassword));
142
+ await db.tx(async (t) => {
143
+ await t.exec("update users set password_hash = ?, must_change = 1 where id = ?", [hash, targetUid]);
144
+ await t.exec("delete from sys_refresh_token where user_id = ?", [targetUid]);
145
+ });
146
+ invalidateUser(Number(targetUid));
147
+ await recordOp({ action: "user.password.reset", module: "framework", target: await usernameOf(Number(targetUid)) });
148
+ }
149
+
150
+ /** B7:禁用/解禁——禁用级联删 refresh + 写时失效。 */
151
+ export async function setForbidden(actorId: number, targetUid: number, forbidden: boolean): Promise<void> {
152
+ assertNotSelf(actorId, targetUid);
153
+ const rows = await db.query("select id from users where id = ?", [targetUid]);
154
+ if (!rows.length)
155
+ fail(404, "user not found");
156
+ await db.tx(async (t) => {
157
+ await t.exec("update users set is_forbidden = ? where id = ?", [forbidden ? 1 : 0, targetUid]);
158
+ if (forbidden)
159
+ await t.exec("delete from sys_refresh_token where user_id = ?", [targetUid]);
160
+ });
161
+ invalidateUser(Number(targetUid));
162
+ await recordOp({ action: "user.forbidden.update", module: "framework", target: await usernameOf(Number(targetUid)), detail: { forbidden } });
163
+ }
@@ -0,0 +1,22 @@
1
+ import { respond } from "../_shared/respond";
2
+ // 平台共享服务域 /api/_platform/contacts(D207:登录即可,不挂权限点,H12)。
3
+ // 读操作不记审计(设计 §4B);接口级限速(设计 line 199)经 rate.ts(B5 复用同源)。
4
+ // 单条详情走子组 /api/_platform/contacts/detail?id=(detail/api.ts,含 email/avatar)。
5
+ import { searchContacts } from "../_shared/service/contacts";
6
+ import { rateLimit } from "../_shared/service/rate";
7
+
8
+ export default {
9
+ // GET /api/_platform/contacts?keyword=&limit= —— 人员目录(AC1-22~24)。
10
+ async get() {
11
+ await respond(async () => {
12
+ const uid = http.user?.id;
13
+ if (!uid) {
14
+ json.fail(401, "unauthorized");
15
+ return;
16
+ }
17
+ await rateLimit(`contacts:${uid}`, 60, 60_000);
18
+ const q = http.query as Record<string, unknown>;
19
+ return searchContacts(q.keyword, { limit: q.limit });
20
+ });
21
+ },
22
+ };
@@ -0,0 +1,18 @@
1
+ import { respond } from "../../_shared/respond";
2
+ // GET /api/_platform/contacts/detail?id= —— 单条详情(含 email/avatar,设计 §4B)。
3
+ import { contactDetail } from "../../_shared/service/contacts";
4
+ import { rateLimit } from "../../_shared/service/rate";
5
+
6
+ export default {
7
+ async get() {
8
+ await respond(async () => {
9
+ const uid = http.user?.id;
10
+ if (!uid) {
11
+ json.fail(401, "unauthorized");
12
+ return;
13
+ }
14
+ await rateLimit(`contacts:${uid}`, 60, 60_000);
15
+ return contactDetail(Number((http.query as Record<string, unknown>).id));
16
+ });
17
+ },
18
+ };
@@ -1,5 +1,13 @@
1
1
  name: _platform
2
- desc: 框架级共有表(users 鉴权)——普通模块、无路由(参考 sample/src/_platform)
2
+ desc: 框架级共有表(users 鉴权 + RBAC 六表 + 设置/日志/路由注册)——普通模块、无路由(参考 sample/src/_platform)
3
3
  version: 0.1.0
4
4
  tables:
5
5
  - users
6
+ - sys_setting
7
+ - sys_oplog
8
+ - sys_solution_route
9
+ - sys_role
10
+ - sys_permission
11
+ - sys_role_permission
12
+ - sys_user_role
13
+ - sys_refresh_token
@@ -0,0 +1,9 @@
1
+ -- users 表 mysql 方言覆盖(方言覆盖文件必胜,通用文件兜底,oj-bin docs/migration.md §3.1;约定同 oj-wiki 先例)。
2
+ -- 差异:AUTOINCREMENT → BIGINT AUTO_INCREMENT;username TEXT 改 VARCHAR(255)(mysql TEXT 不能带 UNIQUE);
3
+ -- roles TEXT 默认值写表达式 DEFAULT ('[]')(mysql ≥8.0.13 对 TEXT/BLOB 只允许表达式默认值)。
4
+ CREATE TABLE IF NOT EXISTS users (
5
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
6
+ username VARCHAR(255) NOT NULL UNIQUE,
7
+ password_hash TEXT NOT NULL,
8
+ roles TEXT NOT NULL DEFAULT ('[]')
9
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,9 @@
1
+ -- users 表 pg 方言覆盖(方言覆盖文件必胜,oj-bin docs/migration.md §3.1;约定同 oj-wiki 先例)。
2
+ -- 差异:AUTOINCREMENT → BIGSERIAL(GENERATED ALWAYS 系显式插值受限,SERIAL 系允许 seed 显式 id);
3
+ -- username TEXT UNIQUE 在 pg 合法,保留 TEXT。
4
+ CREATE TABLE IF NOT EXISTS users (
5
+ id BIGSERIAL PRIMARY KEY,
6
+ username TEXT NOT NULL UNIQUE,
7
+ password_hash TEXT NOT NULL,
8
+ roles TEXT NOT NULL DEFAULT '[]'
9
+ );
@@ -0,0 +1,3 @@
1
+ -- users 补 avatar_base64 的 mysql 方言覆盖(oj-wiki 0002 先例逐字同型)。
2
+ -- 差异:TEXT 列默认值必须写成表达式 DEFAULT ('')。
3
+ ALTER TABLE users ADD COLUMN avatar_base64 TEXT NOT NULL DEFAULT ('');
@@ -0,0 +1,5 @@
1
+ -- users 扩展列 mysql 方言覆盖(oj-wiki 0005/0006 先例同型)。
2
+ -- 差异:TEXT 默认值写表达式 DEFAULT ('local');DROP COLUMN mysql 8 原生支持,与基线同语法。
3
+ ALTER TABLE users ADD COLUMN source TEXT NOT NULL DEFAULT ('local');
4
+ ALTER TABLE users ADD COLUMN display_name TEXT;
5
+ ALTER TABLE users DROP COLUMN roles;
@@ -0,0 +1,5 @@
1
+ -- RBAC 地基:users 扩展列 + 移除 roles JSON 列(D10:sys_user_role 表为唯一权威源)
2
+ -- sqlite 方言(ALTER TABLE DROP COLUMN 需 sqlite ≥ 3.35,Node 22 内建满足)
3
+ ALTER TABLE users ADD COLUMN source TEXT NOT NULL DEFAULT 'local';
4
+ ALTER TABLE users ADD COLUMN display_name TEXT;
5
+ ALTER TABLE users DROP COLUMN roles;
@@ -0,0 +1,70 @@
1
+ -- 框架 RBAC 六表 mysql 方言覆盖(sqlite 版见 0004__sys_rbac.sql;oj-wiki 先例同型)。
2
+ -- 差异:保留字反引号(mysql 默认 sql_mode 下双引号是字符串字面量);
3
+ -- AUTO_INCREMENT 替 AUTOINCREMENT,id 取 BIGINT(与 users 同宽度口径);
4
+ -- 主键/默认值 TEXT → VARCHAR(255) / 表达式默认值(mysql TEXT 不能作主键、不能直接带默认值)。
5
+ CREATE TABLE IF NOT EXISTS sys_setting (
6
+ `key` VARCHAR(255) PRIMARY KEY,
7
+ module TEXT NOT NULL,
8
+ `group` TEXT NOT NULL,
9
+ type TEXT NOT NULL DEFAULT ('string'),
10
+ value TEXT,
11
+ default_value TEXT,
12
+ label_key TEXT,
13
+ options TEXT,
14
+ secret BIGINT NOT NULL DEFAULT 0,
15
+ update_time BIGINT NOT NULL DEFAULT 0
16
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
17
+
18
+ CREATE TABLE IF NOT EXISTS sys_oplog (
19
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
20
+ create_time BIGINT NOT NULL,
21
+ user_id BIGINT NOT NULL DEFAULT 0,
22
+ username TEXT NOT NULL DEFAULT ('system'),
23
+ action TEXT NOT NULL,
24
+ module TEXT NOT NULL DEFAULT ('framework'),
25
+ version TEXT,
26
+ target TEXT,
27
+ detail TEXT,
28
+ level BIGINT NOT NULL DEFAULT 6
29
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
30
+ -- mysql 无 CREATE INDEX IF NOT EXISTS;迁移账本保证一次应用(oj-bin docs/migration.md 语义)
31
+ CREATE INDEX idx_sys_oplog_time ON sys_oplog (create_time, id);
32
+
33
+ CREATE TABLE IF NOT EXISTS sys_solution_route (
34
+ name VARCHAR(255) NOT NULL,
35
+ version VARCHAR(255) NOT NULL,
36
+ category TEXT NOT NULL,
37
+ path VARCHAR(255) NOT NULL,
38
+ `order` BIGINT NOT NULL DEFAULT 0,
39
+ title_key TEXT,
40
+ enabled BIGINT NOT NULL DEFAULT 1,
41
+ PRIMARY KEY (name, version, path)
42
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
43
+
44
+ CREATE TABLE IF NOT EXISTS sys_role (
45
+ name VARCHAR(255) PRIMARY KEY,
46
+ module TEXT NOT NULL,
47
+ title TEXT,
48
+ description TEXT,
49
+ builtin BIGINT NOT NULL DEFAULT 0,
50
+ deprecated BIGINT NOT NULL DEFAULT 0
51
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
52
+
53
+ CREATE TABLE IF NOT EXISTS sys_permission (
54
+ code VARCHAR(255) PRIMARY KEY,
55
+ module TEXT NOT NULL,
56
+ title_key TEXT,
57
+ deprecated BIGINT NOT NULL DEFAULT 0
58
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
59
+
60
+ CREATE TABLE IF NOT EXISTS sys_role_permission (
61
+ role_name VARCHAR(255) NOT NULL,
62
+ permission_code VARCHAR(255) NOT NULL,
63
+ PRIMARY KEY (role_name, permission_code)
64
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
65
+
66
+ CREATE TABLE IF NOT EXISTS sys_user_role (
67
+ user_id BIGINT NOT NULL,
68
+ role_name VARCHAR(255) NOT NULL,
69
+ PRIMARY KEY (user_id, role_name)
70
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;