@oj-module/cli 0.2.50 → 0.2.54

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 (130) hide show
  1. package/devkit/SKILL.md +2 -2
  2. package/devkit/manual.md +32 -25
  3. package/devkit/wecom-internal-app.md +111 -4
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
  6. package/shell-dist/assets/runtime.js +29 -5
  7. package/shell-dist/index.html +9 -12
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/templates-tenant-scope.test.ts +3 -2
  10. package/src/build.ts +19 -2
  11. package/src/config.ts +9 -0
  12. package/src/contract/__tests__/client-path.test.ts +30 -0
  13. package/src/contract/__tests__/emit-client.test.ts +18 -0
  14. package/src/contract/__tests__/handler-scan.test.ts +23 -0
  15. package/src/contract/check.ts +5 -5
  16. package/src/contract/client-path.ts +30 -0
  17. package/src/contract/emit-client.ts +18 -4
  18. package/src/contract/handler-scan.ts +40 -3
  19. package/src/contract/run.ts +5 -3
  20. package/src/init.ts +19 -1
  21. package/src/shared-deps.ts +27 -9
  22. package/src/site-base.ts +1 -1
  23. package/templates/README.md +40 -44
  24. package/templates/init.include.yaml +15 -14
  25. package/templates/openapi.json +3422 -1955
  26. package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
  27. package/templates/src/_platform/api/contacts/api.ts +29 -1
  28. package/templates/src/_platform/api/contacts/detail/api.ts +25 -1
  29. package/templates/src/_platform/api/solutions/api.ts +60 -1
  30. package/templates/src/_platform/web/__client/api.schemas.ts +99 -35
  31. package/templates/src/_platform/web/__client/api.ts +103 -59
  32. package/templates/src/app/web/entry.ts +77 -0
  33. package/templates/src/{home/web → app/web/home}/__client/api.ts +5 -2
  34. package/templates/src/app/web/home/routes.ts +41 -0
  35. package/templates/src/{login/web → app/web/login}/pages/login.tsx +54 -24
  36. package/templates/src/app/web/login/routes.ts +71 -0
  37. package/templates/src/{notification/web → app/web/notification}/__client/api.schemas.ts +14 -0
  38. package/templates/src/{notification/web → app/web/notification}/__client/api.ts +67 -20
  39. package/templates/src/app/web/notification/routes.ts +70 -0
  40. package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.ts +5 -2
  41. package/templates/src/app/web/personal-center/routes.ts +49 -0
  42. package/templates/src/{system/web → app/web/system}/__client/api.schemas.ts +28 -22
  43. package/templates/src/{system/web → app/web/system}/__client/api.ts +103 -58
  44. package/templates/src/{system/web → app/web/system}/locales/en-US.json +14 -0
  45. package/templates/src/{system/web → app/web/system}/locales/zh-CN.json +14 -0
  46. package/templates/src/{system/web → app/web/system}/pages/users/index.tsx +5 -2
  47. package/templates/src/{system/web → app/web/system}/pages/users/roles.tsx +14 -3
  48. package/templates/src/{system/web → app/web/system}/pages/wecom/index.tsx +139 -1
  49. package/templates/src/app/web/system/routes.ts +97 -0
  50. package/templates/src/auth/api/_shared/wecom/admin.ts +4 -0
  51. package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
  52. package/templates/src/auth/api/_shared/wecom/events.ts +3 -0
  53. package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
  54. package/templates/src/auth/api/api.ts +59 -0
  55. package/templates/src/auth/api/ldap-login/api.ts +37 -1
  56. package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
  57. package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
  58. package/templates/src/auth/api/wecom/api.ts +23 -0
  59. package/templates/src/auth/web/__client/api.schemas.ts +133 -13
  60. package/templates/src/auth/web/__client/api.ts +129 -31
  61. package/templates/src/notification/api/api.ts +17 -0
  62. package/templates/src/notification/api/del/api.ts +2 -0
  63. package/templates/src/system/api/roles/api.ts +3 -0
  64. package/templates/src/system/api/roles/bindings/api.ts +3 -0
  65. package/templates/src/system/api/settings/api.ts +6 -3
  66. package/templates/src/system/api/users/forbidden/api.ts +3 -0
  67. package/templates/src/system/api/users/password/api.ts +3 -0
  68. package/templates/src/system/api/users/roles/api.ts +3 -0
  69. package/templates/src/web/api/get-async-routes/api.ts +16 -1
  70. package/templates/src/web/api/hello/api.ts +13 -1
  71. package/templates/src/web/api/user-info/api.ts +21 -1
  72. package/templates/src/web/web/__client/api.schemas.ts +28 -0
  73. package/templates/src/web/web/__client/api.ts +33 -9
  74. package/templates/tsconfig.paths.json +0 -1
  75. package/templates/web.config.ts +22 -17
  76. package/vendor/host-versions.json +3 -3
  77. package/shell-dist/assets/echarts-charts.js +0 -58571
  78. package/shell-dist/assets/echarts-features.js +0 -24057
  79. package/shell-dist/assets/echarts-for-react.js +0 -704
  80. package/shell-dist/assets/echarts.js +0 -82916
  81. package/shell-dist/assets/editor.js +0 -161457
  82. package/shell-dist/assets/vditor-index-css.js +0 -7
  83. package/shell-dist/assets/vditor.js +0 -15223
  84. package/templates/src/demo/api/manifest.yaml +0 -5
  85. package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
  86. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
  87. package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
  88. package/templates/src/demo/api/schema.yaml +0 -12
  89. package/templates/src/demo/api/seed.sql +0 -5
  90. package/templates/src/demo/api/todos/api.ts +0 -59
  91. package/templates/src/demo/web/__client/api.schemas.ts +0 -20
  92. package/templates/src/demo/web/__client/api.ts +0 -89
  93. package/templates/src/demo/web/entry.ts +0 -42
  94. package/templates/src/demo/web/locales/en-US.json +0 -10
  95. package/templates/src/demo/web/locales/zh-CN.json +0 -10
  96. package/templates/src/demo/web/pages/index.tsx +0 -45
  97. package/templates/src/home/web/entry.ts +0 -49
  98. package/templates/src/login/web/entry.ts +0 -79
  99. package/templates/src/notification/web/entry.ts +0 -84
  100. package/templates/src/personal-center/web/entry.ts +0 -60
  101. package/templates/src/system/web/entry.ts +0 -108
  102. /package/templates/src/{home/web → app/web/home}/__client/api.schemas.ts +0 -0
  103. /package/templates/src/{home/web → app/web/home}/locales/en-US.json +0 -0
  104. /package/templates/src/{home/web → app/web/home}/locales/zh-CN.json +0 -0
  105. /package/templates/src/{home/web → app/web/home}/pages/components/bar-chart.tsx +0 -0
  106. /package/templates/src/{home/web → app/web/home}/pages/components/card-list.tsx +0 -0
  107. /package/templates/src/{home/web → app/web/home}/pages/components/line-chart.tsx +0 -0
  108. /package/templates/src/{home/web → app/web/home}/pages/components/pie-chart.tsx +0 -0
  109. /package/templates/src/{home/web → app/web/home}/pages/index.tsx +0 -0
  110. /package/templates/src/{login/web → app/web/login}/auth-provider.ts +0 -0
  111. /package/templates/src/{login/web → app/web/login}/locales/en-US.json +0 -0
  112. /package/templates/src/{login/web → app/web/login}/locales/zh-CN.json +0 -0
  113. /package/templates/src/{login/web → app/web/login}/pages/change-password.tsx +0 -0
  114. /package/templates/src/{login/web → app/web/login}/wecom.ts +0 -0
  115. /package/templates/src/{notification/web → app/web/notification}/locales/en-US.json +0 -0
  116. /package/templates/src/{notification/web → app/web/notification}/locales/zh-CN.json +0 -0
  117. /package/templates/src/{notification/web → app/web/notification}/pages/center/index.tsx +0 -0
  118. /package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.schemas.ts +0 -0
  119. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/en-US.json +0 -0
  120. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/zh-CN.json +0 -0
  121. /package/templates/src/{personal-center/web → app/web/personal-center}/pages/my-profile/index.tsx +0 -0
  122. /package/templates/src/{system/web → app/web/system}/pages/departments/index.tsx +0 -0
  123. /package/templates/src/{system/web → app/web/system}/pages/departments/sync-bar.tsx +0 -0
  124. /package/templates/src/{system/web → app/web/system}/pages/departments/tree.ts +0 -0
  125. /package/templates/src/{system/web → app/web/system}/pages/errors.ts +0 -0
  126. /package/templates/src/{system/web → app/web/system}/pages/forbidden.tsx +0 -0
  127. /package/templates/src/{system/web → app/web/system}/pages/login-methods/index.tsx +0 -0
  128. /package/templates/src/{system/web → app/web/system}/pages/oplog/index.tsx +0 -0
  129. /package/templates/src/{system/web → app/web/system}/pages/roles/index.tsx +0 -0
  130. /package/templates/src/{system/web → app/web/system}/pages/settings/index.tsx +0 -0
@@ -0,0 +1,97 @@
1
+ import type { AppRouteRecordRaw, ModuleContext } from "@oj-module/runtime";
2
+
3
+ import { SettingOutlined } from "@ant-design/icons";
4
+
5
+ import { createElement, lazy } from "react";
6
+
7
+ import { createSystemClient } from "./__client/api";
8
+
9
+ const Users = lazy(() => import("./pages/users"));
10
+ const UserRoles = lazy(() => import("./pages/users/roles"));
11
+ const Departments = lazy(() => import("./pages/departments"));
12
+ const Roles = lazy(() => import("./pages/roles"));
13
+ const Oplog = lazy(() => import("./pages/oplog"));
14
+ const Settings = lazy(() => import("./pages/settings"));
15
+ const LoginMethods = lazy(() => import("./pages/login-methods"));
16
+ const WecomAdmin = lazy(() => import("./pages/wecom"));
17
+
18
+ /**
19
+ * `/system` 根的角色门禁(对齐后端 assertSystemAdmin,access.ts:87)——
20
+ * superadmin **或** admin 均可,两者必须同时保留:D12(2026-10-22)起账号名=角色名,
21
+ * admin 与 superadmin 是两个独立账号(seed.sql),只留 ["admin"] 会把 superadmin
22
+ * 挡在门外(注册期过滤 → 路由消失 → 登录后 404 兜底)。改这两个值前先跑
23
+ * tests/system-guard.regression.spec.ts。
24
+ */
25
+ const SYSTEM_ROLES = ["admin", "superadmin"];
26
+
27
+ /**
28
+ * 系统管理根 `/system`:框架管理面(用户/成员资格/角色权限/操作日志/站点设置/登录方式),
29
+ * 数据源为 oj 后端 /api/system/*(模板 api 模块)。__client 由 `ojm api` 生成。
30
+ *
31
+ * 路由级门禁(B16)挂在根上:非系统管理员整棵子树不注入、菜单不可见(等价于原先的
32
+ * 模块级 requiredRoles——聚合后模块级会把 /home 一起挡掉,故必须下移到根路由);
33
+ * API 级 403 由 assertSystemAdmin + service 层硬 enforcement 兜底。
34
+ */
35
+ export const routes: AppRouteRecordRaw[] = [
36
+ {
37
+ path: "/system",
38
+ handle: {
39
+ icon: createElement(SettingOutlined),
40
+ title: "system:menu.system",
41
+ // 负 order = 底部固定区:系统管理 pin 在侧边栏底部(2026-10-20 序轴约定)
42
+ order: -100,
43
+ layout: "container",
44
+ roles: SYSTEM_ROLES,
45
+ },
46
+ children: [
47
+ {
48
+ path: "/system/users",
49
+ Component: Users,
50
+ handle: { title: "system:menu.users" },
51
+ },
52
+ {
53
+ // 成员资格(superadmin 入口从用户管理页进入,菜单不出现)
54
+ path: "/system/users/:uid/roles",
55
+ Component: UserRoles,
56
+ handle: { title: "system:menu.userRoles", hideInMenu: true },
57
+ },
58
+ {
59
+ path: "/system/departments",
60
+ Component: Departments,
61
+ handle: { title: "system:menu.departments" },
62
+ },
63
+ {
64
+ path: "/system/roles",
65
+ Component: Roles,
66
+ handle: { title: "system:menu.roles" },
67
+ },
68
+ {
69
+ path: "/system/oplog",
70
+ Component: Oplog,
71
+ handle: { title: "system:menu.oplog" },
72
+ },
73
+ {
74
+ // 站点设置(证书/站点级开关):仅超管可见可访问
75
+ path: "/system/settings",
76
+ Component: Settings,
77
+ handle: { title: "system:menu.settings", roles: ["superadmin"] },
78
+ },
79
+ {
80
+ path: "/system/login-methods",
81
+ Component: LoginMethods,
82
+ handle: { title: "system:menu.loginMethods" },
83
+ },
84
+ {
85
+ // 企微代开发后台(服务商运营面):仅超管可见(路由级 roles + 页面内二次判定)
86
+ path: "/system/wecom",
87
+ Component: WecomAdmin,
88
+ handle: { title: "system:menu.wecom", roles: ["superadmin"] },
89
+ },
90
+ ],
91
+ },
92
+ ];
93
+
94
+ /** 构造即接线:登记 /system 前缀 + 绑定 scoped request(D11 前缀收敛)。 */
95
+ export async function onInit(ctx: ModuleContext) {
96
+ createSystemClient(ctx);
97
+ }
@@ -13,6 +13,7 @@ import type { WecomConfig, WecomCorpConfig, WecomKind } from "./config";
13
13
  import type { WecomStoreDeps } from "./store";
14
14
  import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
15
15
  import { assertProviderConfig, eventUrlOf } from "./config";
16
+ import { provisionTenantAdmin } from "./tenant-admin";
16
17
  import { corpScope, revokeTenantSessions, saveProfile, tenantOfCorp } from "./store";
17
18
 
18
19
  export interface WecomAdminDeps {
@@ -194,6 +195,9 @@ export async function connectCorp(cfg: WecomCorpConfig, client: WecomClient, dep
194
195
  }).run();
195
196
  await client.invalidateCorpToken(cfg.corpId, old);
196
197
  }
198
+ // 租户具象化:给这个企业配一个本租户的管理员(admin,非 superadmin),否则
199
+ // 该租户里只有 JIT 出来的员工号,没人能做组织架构同步、也没人能在租户内授权。
200
+ await provisionTenantAdmin(cfg.corpId);
197
201
  invalidateAllUsers();
198
202
  return { corpId: cfg.corpId, agentId: cfg.agentId, departments: depts.length, members };
199
203
  }
@@ -518,11 +518,18 @@ export function createWecomClient(deps: WecomClientDeps) {
518
518
  await invalidateCorpToken(corpId, agentId);
519
519
  continue;
520
520
  }
521
- if (errcode !== 0)
521
+ if (errcode !== 0) {
522
+ // 部署侧错误(IP 白名单 / 限流)不是用户凭据问题:按 502 抛,避免被统一成 401 把排查带偏
523
+ log.warn("wecom getuserinfo rejected", "errcode", String(errcode), "errmsg", String(res.errmsg ?? ""));
524
+ if (errcode === 60020 || errcode === 45009)
525
+ throw upstream(`企微 auth/getuserinfo 失败:errcode=${errcode} ${String(res.errmsg ?? "")}`.trim());
522
526
  rejectCode();
527
+ }
523
528
  const userid = res.userid == null ? "" : String(res.userid);
524
- if (!userid)
529
+ if (!userid) {
530
+ log.warn("wecom getuserinfo no userid", "keys", Object.keys(res).join(","));
525
531
  rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
532
+ }
526
533
  return userid;
527
534
  }
528
535
  throw upstream("企微 token 反复失效(40001/42001)");
@@ -21,6 +21,7 @@ import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
21
21
  import { recordOp } from "#/_platform/_shared/service/infra/audit";
22
22
  import { handleContactChange } from "./contacts";
23
23
  import { callbackFields, field, requireField } from "./payload";
24
+ import { provisionTenantAdmin } from "./tenant-admin";
24
25
  import { corpScope, ensureTenant, findCorpAuth, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setAuthMode, setProfileStatus, tenantOfCorp } from "./store";
25
26
 
26
27
  /** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
@@ -117,6 +118,8 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
117
118
  // secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
118
119
  await client.invalidateCorpToken(granted.corpId, agentId);
119
120
  }
121
+ // 租户具象化:代开发企业授权成功即配一个本租户管理员(尽力而为,绝不拖垮 1 秒回调窗口)
122
+ await provisionTenantAdmin(granted.corpId);
120
123
  await auditAuth("wecom.auth.apply", granted.corpId, { agentIds: granted.agentIds, env });
121
124
  return { corpId: granted.corpId, agentIds: granted.agentIds };
122
125
  }
@@ -0,0 +1,194 @@
1
+ // 租户管理员:给每个企业租户(tenant_id = corp_id)配一个**本租户内**的管理员账号。
2
+ //
3
+ // 为什么必须有这一层:
4
+ // 企业租户是企微登录时才由 `ensureTenant()` 隐式具象化的,里面只有 JIT 建出来的
5
+ // `wecom:<userid>` 员工号,**一个管理员都没有**——于是没人能进 /system/departments
6
+ // 做组织架构同步、也没人能在该租户内授权。平台 superadmin 虽然在 /system/wecom
7
+ // 上看得到所有企业,但管不了租户内的组织/成员/角色(详见下面「越权」一段)。
8
+ //
9
+ // 为什么是 `admin` 而不是 `superadmin`:
10
+ // `superadmin` 在本系统是**跨租户**的框架级角色——/system/wecom 每个端点都过
11
+ // `assertSuperAdmin()`,而它列出的 listApps() 是**所有企业**的档案。只要某租户里
12
+ // 有人握着 superadmin,他带自己租户的头访问该页就能看到别家企业的档案,属跨租户
13
+ // 越权。所以:superadmin = 平台级、只留在 default 租户(运维纪律);
14
+ // admin = 租户级、每租户一个(本文件负责开的就是这个)。
15
+ //
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // 【db.asSystem 例外说明 —— 全仓唯一,不得复制】
18
+ //
19
+ // 本文件是全代码库**唯一**使用 `db.asSystem()` 的地方。
20
+ //
21
+ // 为什么必须用它:superadmin 的请求自带 `default` 租户头,而 `db.asTenant` 是
22
+ // 「请求级、只能设一次,且要求当时还没有租户」。也就是说平台侧**没有任何正常通道**
23
+ // 能往企业租户里写 users / sys_user_role。而「给新租户配管理员」是平台开通动作,
24
+ // 不是业务写——正是逃生通道的用途(global.d.ts 原文:"use db.asSystem() for system tasks")。
25
+ //
26
+ // 框架红线:同一段注释紧接着写「业务 handler 不得使用」。所以这里是**有意识且被审计
27
+ // 的单一例外**,代价与边界必须写清楚:
28
+ // · 范围:只在本文件,只服务「开通 / 重设租户管理员」这一件事;
29
+ // · 写操作必须显式带上 tenant_id 条件(asSystem 绕过自动注入,漏了就是跨租户脏写);
30
+ // · 每个入口都在 admin/api.ts 走 recordOp 留痕(开通 level 2、改密 level 4);
31
+ // · **不得在本文件之外任何地方复制 db.asSystem()**。
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ /** 租户管理员的固定账号名(每租户各一个,互不冲突——users 唯一键含 tenant_id) */
35
+ export const TENANT_ADMIN_USERNAME = "admin";
36
+ /** 授予的角色:租户级日常管理(组织/成员/角色/同步),**不是**跨租户的 superadmin */
37
+ const TENANT_ADMIN_ROLE = "admin";
38
+
39
+ export interface TenantAdminState {
40
+ /** 租户(= 企业 ID) */
41
+ corpId: string
42
+ /** 管理员账号名 */
43
+ username: string
44
+ /** 该租户是否已有一个握着 admin 角色的账号 */
45
+ exists: boolean
46
+ /** 是否处于「须改密」状态(开通后恒为 true,直到本人改过一次) */
47
+ mustChange: boolean
48
+ }
49
+
50
+ /**
51
+ * 随机初始口令:**任何人都不知道**(bcrypt 单向,也不落库、不打日志)。
52
+ *
53
+ * 这不是缺陷而是设计:开通时不需要有人知道口令;平台侧要接管时走
54
+ * `setTenantAdminPassword()` **自己设一个已知的**——他输入的那个他本来就知道,
55
+ * 于是「口令需要被传递」这个问题被消掉了,全系统没有任何口令以可逆形式静态存储。
56
+ */
57
+ function randomPassword(): string {
58
+ // randomHex 产出 [0-9a-f],长度 20 > 口令最小长度 6,且数字字母混合
59
+ return crypto.randomHex(10);
60
+ }
61
+
62
+ /** 跨租户读:指定租户下的管理员行(asSystem 例外,见文件头) */
63
+ async function adminRow(tenantId: string): Promise<{ id: number, must_change: number } | null> {
64
+ const rows = await db.asSystem().table("users")
65
+ .select(["id", "must_change"])
66
+ .where({
67
+ and: [
68
+ { field: "tenant_id", op: "eq", value: tenantId },
69
+ { field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
70
+ ],
71
+ })
72
+ .all() as Array<{ id: number, must_change: number | null }>;
73
+ if (!rows.length)
74
+ return null;
75
+ return { id: Number(rows[0]!.id), must_change: Number(rows[0]!.must_change ?? 0) };
76
+ }
77
+
78
+ /** 跨租户读:该用户在本租户是否握着 admin 角色(账号存在 ≠ 有角色,可能被收回过) */
79
+ async function hasAdminRole(tenantId: string, uid: number): Promise<boolean> {
80
+ const roles = await db.asSystem().table("sys_user_role")
81
+ .select(["role_name"])
82
+ .where({
83
+ and: [
84
+ { field: "tenant_id", op: "eq", value: tenantId },
85
+ { field: "user_id", op: "eq", value: uid },
86
+ ],
87
+ })
88
+ .all() as Array<{ role_name: string }>;
89
+ return roles.some(r => String(r.role_name) === TENANT_ADMIN_ROLE);
90
+ }
91
+
92
+ /** 查看某租户的管理员状态(幂等、只读;不存在不建) */
93
+ export async function tenantAdminState(tenantId: string): Promise<TenantAdminState> {
94
+ const row = await adminRow(tenantId);
95
+ if (!row)
96
+ return { corpId: tenantId, username: TENANT_ADMIN_USERNAME, exists: false, mustChange: false };
97
+ return {
98
+ corpId: tenantId,
99
+ username: TENANT_ADMIN_USERNAME,
100
+ exists: await hasAdminRole(tenantId, row.id),
101
+ mustChange: row.must_change === 1,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 开通租户管理员(幂等):没有就建,有就只确认角色还在。
107
+ *
108
+ * 只做两件事:建 `admin` 账号(随机口令 + 强制改密)、授予本租户的 `admin` 角色。
109
+ * 重复调用安全——这正是「验证并接入可以反复点」的前提。
110
+ */
111
+ export async function ensureTenantAdmin(tenantId: string): Promise<{ created: boolean }> {
112
+ let row = await adminRow(tenantId);
113
+
114
+ if (!row) {
115
+ // 与 ensureWecomUser 同形状:只填业务关心的列,其余走列默认值
116
+ await db.asSystem().table("users").insert({
117
+ tenant_id: tenantId,
118
+ username: TENANT_ADMIN_USERNAME,
119
+ display_name: "租户管理员",
120
+ password_hash: await bcrypt.hash(randomPassword()),
121
+ avatar_base64: "",
122
+ source: "local",
123
+ must_change: 1,
124
+ is_forbidden: 0,
125
+ activated: 1,
126
+ }).run();
127
+ // 回查拿 id:预查与插入之间有竞态(并发开通),回查是唯一可靠的取 id 方式
128
+ row = await adminRow(tenantId);
129
+ }
130
+ if (!row)
131
+ throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
132
+
133
+ if (!await hasAdminRole(tenantId, row.id)) {
134
+ await db.asSystem().table("sys_user_role").insert({
135
+ user_id: row.id,
136
+ role_name: TENANT_ADMIN_ROLE,
137
+ tenant_id: tenantId,
138
+ }).run();
139
+ return { created: true };
140
+ }
141
+ return { created: false };
142
+ }
143
+
144
+ /**
145
+ * 开通租户管理员(**尽力而为**):开不通只告警,绝不打断调用方。
146
+ *
147
+ * 为什么调用方不能因为它失败:
148
+ * · create_auth 有约 1 秒响应窗口(超时企业管理员看到「应用安装失败」),而 bcrypt
149
+ * 是 CPU 密集操作,不该让它有机会拖垮回调;
150
+ * · connectCorp 走到这里时档案**已经落库**,抛错只会让界面显示「接入失败」而实际已接入。
151
+ *
152
+ * 补开通道:superadmin 随时可走「重设租户管理员口令」入口,该入口内部会先
153
+ * `ensureTenantAdmin()`,所以一次开通失败不会留下没有管理员的死租户。
154
+ */
155
+ export async function provisionTenantAdmin(tenantId: string): Promise<void> {
156
+ try {
157
+ const r = await ensureTenantAdmin(tenantId);
158
+ log.info("wecom tenant admin provisioned", "corpId", tenantId, "created", r.created);
159
+ }
160
+ catch (e) {
161
+ log.warn("wecom tenant admin provision skipped", "corpId", tenantId, "reason", (e as Error).message);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 重设租户管理员口令(平台 superadmin 专用入口)。
167
+ *
168
+ * 口令**不存储**:superadmin 自己输入新口令 → 他知道 → 告知租户管理员。
169
+ * 落库的只有 bcrypt,且置 must_change=1(首次登录必须改),并级联吊销该用户的
170
+ * refresh token,避免旧会话继续有效。
171
+ */
172
+ export async function setTenantAdminPassword(tenantId: string, password: string): Promise<TenantAdminState> {
173
+ // 先确保账号与角色都在:允许「还没人登录过」的租户被平台直接接管
174
+ await ensureTenantAdmin(tenantId);
175
+ const row = await adminRow(tenantId);
176
+ if (!row)
177
+ throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
178
+
179
+ await db.asSystem().table("users")
180
+ .update({ password_hash: await bcrypt.hash(password), must_change: 1 })
181
+ .where({
182
+ and: [
183
+ { field: "tenant_id", op: "eq", value: tenantId },
184
+ { field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
185
+ ],
186
+ })
187
+ .run();
188
+
189
+ // 改密后旧会话必须失效。跨租户删 refresh 走带 tenant_id 条件的裸 SQL
190
+ // (先例见 store.ts::revokeTenantSessions:守卫是 check-on-miss,带了租户条件即放行)
191
+ await db.exec("DELETE FROM sys_refresh_token WHERE tenant_id = ? AND user_id = ?", [tenantId, row.id]);
192
+
193
+ return await tenantAdminState(tenantId);
194
+ }
@@ -9,6 +9,30 @@ import { createSession } from "./_shared/session";
9
9
  // 模块级禁止 createSession():oj build 的 introspect 在无 jwt 的空沙箱里评估模块(§9.4),
10
10
  // runtimeDeps() 读 g.jwt 会直接炸构建——session 在 handler 内按需构造(无状态,纯闭包)。
11
11
 
12
+ /** 会话票据(TokenBundle):登录 / 刷新 / 改密三处同形(D14 + AC1-27) */
13
+ const tokenBundle = {
14
+ type: "object",
15
+ required: ["access_token", "refresh_token", "expires_in", "restricted", "user"],
16
+ properties: {
17
+ access_token: { type: "string" },
18
+ refresh_token: { type: "string" },
19
+ expires_in: { type: "number" },
20
+ restricted: { type: "boolean" },
21
+ user: {
22
+ type: "object",
23
+ required: ["id", "username", "display_name", "avatar_base64", "must_change", "roles"],
24
+ properties: {
25
+ id: { type: "number" },
26
+ username: { type: "string" },
27
+ display_name: { type: ["string", "null"] },
28
+ avatar_base64: { type: ["string", "null"] },
29
+ must_change: { type: "boolean" },
30
+ roles: { type: "array", items: { type: "string" } },
31
+ },
32
+ },
33
+ },
34
+ };
35
+
12
36
  const login = {
13
37
  // POST /api/auth/login —— 本地登录(D14:先验口令后查禁用,三种失败统一 401)。
14
38
  // B5/AC1-26:(IP, username) 连败阈值 → 429 + 指数 backoff;粗粒度 per-IP 窗口防撞库扫描。
@@ -73,4 +97,39 @@ password.put.schema = {
73
97
  },
74
98
  };
75
99
 
100
+ // 入参:本地账号口令(两种失败统一 401,不泄露账号状态——D14)
101
+ login.post.schema = {
102
+ body: {
103
+ type: "object",
104
+ required: ["username", "password"],
105
+ properties: {
106
+ username: { type: "string", minLength: 1 },
107
+ password: { type: "string", minLength: 1 },
108
+ },
109
+ },
110
+ };
111
+ login.post.response = tokenBundle;
112
+
113
+ // 登出:吊销给定 refresh 串(无业务返回体)
114
+ logout.post.schema = {
115
+ body: {
116
+ type: "object",
117
+ required: ["refresh_token"],
118
+ properties: { refresh_token: { type: "string", minLength: 1 } },
119
+ },
120
+ };
121
+ logout.post.response = { type: "null" };
122
+
123
+ // 刷新:旧串轮换(reuse 检测在 service 内)
124
+ refresh.post.schema = {
125
+ body: {
126
+ type: "object",
127
+ required: ["refresh_token"],
128
+ properties: { refresh_token: { type: "string", minLength: 1 } },
129
+ },
130
+ };
131
+ refresh.post.response = tokenBundle;
132
+
133
+ password.put.response = tokenBundle;
134
+
76
135
  export default { login, logout, refresh, password };
@@ -7,7 +7,7 @@ import { createSession } from "../_shared/session";
7
7
 
8
8
  // 模块级禁止 createSession()/runtimeLdapGateway():oj build introspect 的空沙箱无 jwt/axis(§9.4)。
9
9
 
10
- export default {
10
+ const handler = {
11
11
  async post() {
12
12
  const body = http.body || {};
13
13
  const username = String(body.username ?? "");
@@ -27,3 +27,39 @@ export default {
27
27
  });
28
28
  },
29
29
  };
30
+
31
+ // 入参与本地登录同形(username/password);出参同 TokenBundle
32
+ handler.post.schema = {
33
+ body: {
34
+ type: "object",
35
+ required: ["username", "password"],
36
+ properties: {
37
+ username: { type: "string", minLength: 1 },
38
+ password: { type: "string", minLength: 1 },
39
+ },
40
+ },
41
+ };
42
+ handler.post.response = {
43
+ type: "object",
44
+ required: ["access_token", "refresh_token", "expires_in", "restricted", "user"],
45
+ properties: {
46
+ access_token: { type: "string" },
47
+ refresh_token: { type: "string" },
48
+ expires_in: { type: "number" },
49
+ restricted: { type: "boolean" },
50
+ user: {
51
+ type: "object",
52
+ required: ["id", "username", "display_name", "avatar_base64", "must_change", "roles"],
53
+ properties: {
54
+ id: { type: "number" },
55
+ username: { type: "string" },
56
+ display_name: { type: ["string", "null"] },
57
+ avatar_base64: { type: ["string", "null"] },
58
+ must_change: { type: "boolean" },
59
+ roles: { type: "array", items: { type: "string" } },
60
+ },
61
+ },
62
+ },
63
+ };
64
+
65
+ export default handler;
@@ -0,0 +1,41 @@
1
+ // GET /api/auth/wecom/admin/tenant-admin?corpId= —— 查某企业的租户管理员状态。
2
+ //
3
+ // 权限:仅 superadmin。这是**跨租户**的平台级视图(查的是别家企业的管理员有没有开通),
4
+ // 与同目录其它端点一致。只读,不存在也不建——建号由开通流程与 password 端点负责。
5
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
6
+ import { respond } from "#/_platform/_shared/respond";
7
+ import { tenantAdminState } from "../../../_shared/wecom/tenant-admin";
8
+
9
+ async function get() {
10
+ await respond(async () => {
11
+ await assertSuperAdmin();
12
+ const corpId = String(((http.query ?? {}) as Record<string, unknown>).corpId ?? "").trim();
13
+ if (!corpId)
14
+ throw Object.assign(new Error("缺少企业代码"), { status: 400 });
15
+ return tenantAdminState(corpId);
16
+ });
17
+ }
18
+
19
+ get.schema = {
20
+ query: {
21
+ type: "object",
22
+ required: ["corpId"],
23
+ properties: { corpId: { type: "string", minLength: 1 } },
24
+ },
25
+ };
26
+
27
+ get.response = {
28
+ type: "object",
29
+ required: ["corpId", "username", "exists", "mustChange"],
30
+ properties: {
31
+ corpId: { type: "string" },
32
+ // 固定账号名:每租户各一个(users 唯一键含 tenant_id,互不影响)
33
+ username: { type: "string" },
34
+ // 该租户是否已有一个握着 admin 角色的账号
35
+ exists: { type: "boolean" },
36
+ // 是否须改密(开通后恒 true,直到本人改过一次)
37
+ mustChange: { type: "boolean" },
38
+ },
39
+ };
40
+
41
+ export default { get };
@@ -0,0 +1,66 @@
1
+ // POST /api/auth/wecom/admin/tenant-admin/password —— 重设某企业的租户管理员口令。
2
+ //
3
+ // 权限:仅 superadmin(跨租户的平台级动作)。
4
+ //
5
+ // 为什么做成「superadmin 自己输入新口令」而不是「系统生成后回显」:
6
+ // 口令只以 bcrypt 落库,**没有任何可逆存储**,所以系统无法回显一个已存在的口令。
7
+ // 让 superadmin 自己输入,他知道这个值 → 直接告知租户管理员即可。代价为零,
8
+ // 收益是全系统不存在静态可读的口令副本。
9
+ //
10
+ // 副作用(都是必需的):
11
+ // · must_change = 1 —— 管理员首次登录必须改掉这个临时口令;
12
+ // · 级联删 refresh —— 改密后旧会话立即失效,避免旧 token 继续可用。
13
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
14
+ import { recordOp } from "#/_platform/_shared/service/infra/audit";
15
+ import { respond } from "#/_platform/_shared/respond";
16
+ import { setTenantAdminPassword } from "../../../../_shared/wecom/tenant-admin";
17
+
18
+ async function post() {
19
+ await respond(async () => {
20
+ await assertSuperAdmin();
21
+ const body = (http.body ?? {}) as { corpId?: unknown, password?: unknown };
22
+ const corpId = String(body.corpId ?? "").trim();
23
+ const password = String(body.password ?? "");
24
+ if (!corpId)
25
+ throw Object.assign(new Error("缺少企业代码"), { status: 400 });
26
+ // 与 users/password 同下限(6 位):太短的口令在交接环节泄露风险更高
27
+ if (password.length < 6)
28
+ throw Object.assign(new Error("口令至少 6 位"), { status: 400 });
29
+
30
+ const state = await setTenantAdminPassword(corpId, password);
31
+ await recordOp({
32
+ action: "wecom.admin.tenant_admin_password",
33
+ module: "auth",
34
+ target: corpId,
35
+ // 不记口令本身(连脱敏都不记):审计只要能回答「谁在何时给哪个企业重设过」
36
+ detail: { username: state.username },
37
+ // 重设他人管理员口令=接管该租户的最高权限,按安全事件留痕
38
+ level: 4,
39
+ });
40
+ return state;
41
+ });
42
+ }
43
+
44
+ post.schema = {
45
+ body: {
46
+ type: "object",
47
+ required: ["corpId", "password"],
48
+ properties: {
49
+ corpId: { type: "string", minLength: 1 },
50
+ password: { type: "string", minLength: 6 },
51
+ },
52
+ },
53
+ };
54
+
55
+ post.response = {
56
+ type: "object",
57
+ required: ["corpId", "username", "exists", "mustChange"],
58
+ properties: {
59
+ corpId: { type: "string" },
60
+ username: { type: "string" },
61
+ exists: { type: "boolean" },
62
+ mustChange: { type: "boolean" },
63
+ },
64
+ };
65
+
66
+ export default { post };
@@ -320,6 +320,29 @@ ticket.post.schema = {
320
320
  },
321
321
  },
322
322
  };
323
+ // 出参:TicketBundle(issueExternal 签发,同 TokenBundle)
324
+ ticket.post.response = {
325
+ type: "object",
326
+ required: ["access_token", "refresh_token", "expires_in", "restricted", "user"],
327
+ properties: {
328
+ access_token: { type: "string" },
329
+ refresh_token: { type: "string" },
330
+ expires_in: { type: "number" },
331
+ restricted: { type: "boolean" },
332
+ user: {
333
+ type: "object",
334
+ required: ["id", "username", "display_name", "avatar_base64", "must_change", "roles"],
335
+ properties: {
336
+ id: { type: "number" },
337
+ username: { type: "string" },
338
+ display_name: { type: ["string", "null"] },
339
+ avatar_base64: { type: ["string", "null"] },
340
+ must_change: { type: "boolean" },
341
+ roles: { type: "array", items: { type: "string" } },
342
+ },
343
+ },
344
+ },
345
+ };
323
346
 
324
347
  const event = {
325
348
  // 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)。