@oj-module/cli 0.2.46 → 0.2.48

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 (97) hide show
  1. package/devkit/SKILL.md +1 -1
  2. package/devkit/framework-dev.md +2 -2
  3. package/devkit/manual.md +24 -14
  4. package/devkit/module-dev-and-cdn-playground.md +2 -2
  5. package/devkit/wecom-daikaifa.md +282 -27
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/templates-tenant-scope.test.ts +7 -2
  11. package/src/init.ts +1 -1
  12. package/src/market/__tests__/bootstrap.test.ts +1 -1
  13. package/src/market/__tests__/setup.test.ts +7 -7
  14. package/src/market/bootstrap.ts +1 -1
  15. package/templates/README.md +1 -1
  16. package/templates/config.yaml +26 -7
  17. package/templates/init.include.yaml +12 -0
  18. package/templates/openapi.json +2863 -549
  19. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  21. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  22. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  23. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  24. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  25. package/templates/src/_platform/api/manifest.yaml +3 -0
  26. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  27. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  28. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  29. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  30. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  31. package/templates/src/_platform/api/org/members/api.ts +55 -0
  32. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  33. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  34. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  35. package/templates/src/_platform/api/org/user/api.ts +46 -0
  36. package/templates/src/_platform/api/schema.yaml +48 -0
  37. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  38. package/templates/src/_platform/api/seed.pg.sql +10 -5
  39. package/templates/src/_platform/api/seed.sql +13 -5
  40. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  41. package/templates/src/_platform/web/__client/api.ts +170 -1
  42. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  43. package/templates/src/auth/api/_shared/session.ts +5 -1
  44. package/templates/src/auth/api/_shared/wecom/admin.ts +17 -10
  45. package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
  46. package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
  47. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  48. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  49. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  50. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  51. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  52. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  53. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  54. package/templates/src/auth/api/manifest.yaml +3 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  57. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  58. package/templates/src/auth/api/schema.yaml +31 -0
  59. package/templates/src/auth/api/wecom/admin/api.ts +7 -5
  60. package/templates/src/auth/api/wecom/api.ts +100 -18
  61. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  62. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  63. package/templates/src/auth/web/__client/api.ts +73 -1
  64. package/templates/src/login/web/auth-provider.ts +9 -1
  65. package/templates/src/notification/api/seed.sql +1 -1
  66. package/templates/src/system/api/departments/api.ts +142 -0
  67. package/templates/src/system/api/departments/detach/api.ts +34 -0
  68. package/templates/src/system/api/departments/members/api.ts +142 -0
  69. package/templates/src/system/api/departments/move/api.ts +39 -0
  70. package/templates/src/system/api/users/api.ts +45 -5
  71. package/templates/src/system/api/users/profile/api.ts +44 -0
  72. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  73. package/templates/src/system/web/__client/api.ts +293 -1
  74. package/templates/src/system/web/entry.ts +6 -0
  75. package/templates/src/system/web/locales/en-US.json +113 -7
  76. package/templates/src/system/web/locales/zh-CN.json +113 -7
  77. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  78. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  79. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  80. package/templates/src/system/web/pages/errors.ts +1 -1
  81. package/templates/src/system/web/pages/users/index.tsx +49 -7
  82. package/templates/src/system/web/pages/wecom/index.tsx +16 -1
  83. package/templates/tests/l1-builder.test.ts +1 -1
  84. package/templates/tests/org-departments.spec.ts +311 -0
  85. package/templates/tests/org-read.spec.ts +469 -0
  86. package/templates/tests/rbac-contacts.spec.ts +13 -3
  87. package/templates/tests/rbac-seed.spec.ts +11 -0
  88. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  89. package/templates/tests/wecom-admin.spec.ts +64 -42
  90. package/templates/tests/wecom-client-timeout.spec.ts +99 -0
  91. package/templates/tests/wecom-connector.spec.ts +115 -11
  92. package/templates/tests/wecom-crypt.spec.ts +12 -3
  93. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  94. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  95. package/templates/tests/wecom-sync.spec.ts +286 -0
  96. package/vendor/host-versions.json +3 -3
  97. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -2,6 +2,11 @@
2
2
  // 目录红线:只出 id/username/display_name——不含 email/avatar/roles(防低权账号拖走全员邮箱喂钓鱼,安全评审 P2);
3
3
  // 详情端点才给 email/avatar。默认排除 is_forbidden=1(AC1-23:管理域仍可见,目录与管理分离)。
4
4
  // keyword≥2、LIKE 转义复用 query.ts;solutions 同 isolate import searchContacts 直调,无 HTTP 往返(AC1-25)。
5
+ //
6
+ // ⚡ **只出 activated=1**(方案 §9.1 阻断项):本表设计时隐含「users 里都是本人登录过的账号」,
7
+ // 组织架构同步会为「从未登录本站、也从未同意过」的全员建档——前提一破,任何登录账号都能以
8
+ // 60 次/分把整份企业名册 keyword 遍历走。activated 即「这个号被真人用过」的证据:
9
+ // 管理员建号 / 各来源登录建档置 1,同步建档默认 0(B2 才引入同步,届时显式写 0)。
5
10
  export interface ContactRow {
6
11
  id: number
7
12
  username: string
@@ -22,6 +27,7 @@ export async function searchContacts(keyword: unknown, opts: { limit?: unknown }
22
27
  return await db.table("users")
23
28
  .select(["id", "username", "display_name"])
24
29
  .where({ field: "is_forbidden", op: "eq", value: 0 })
30
+ .where({ field: "activated", op: "eq", value: 1 })
25
31
  .where({ or: [
26
32
  { field: "username", op: "contains", value: kw },
27
33
  { field: "display_name", op: "contains", value: kw },
@@ -36,6 +42,7 @@ export async function contactDetail(id: number): Promise<Record<string, unknown>
36
42
  .select(["id", "username", "display_name", "email", "avatar_base64"])
37
43
  .where({ field: "id", op: "eq", value: id })
38
44
  .where({ field: "is_forbidden", op: "eq", value: 0 })
45
+ .where({ field: "activated", op: "eq", value: 1 })
39
46
  .all();
40
47
  if (!rows[0])
41
48
  throw Object.assign(new Error("contact not found"), { status: 404 });
@@ -0,0 +1,451 @@
1
+ // 组织架构共享读面(方案 A3 / §5.2):给**别的模块**消费的只读面。
2
+ // 与管理端 /api/system/departments 分家——那边是管理语义(出 origin / sync_visible / deleted /
3
+ // 负责人,要管理员),这边是消费语义(只给「看与算」需要的最小集)。
4
+ //
5
+ // 两级门禁(D5;方案 §3 与 §12-D1 已推翻「登录即可」):
6
+ // · scope=self(默认):只看「我在哪」——我所在部门 + 它们的祖先链(自身路径),
7
+ // 以及这些部门里的直属成员;
8
+ // · scope=all:看全量,挂权限点 platform.org.read.all(superadmin / admin 天然通过其一切)。
9
+ //
10
+ // 读面一律**不出** origin(归属权=管理语义)、is_leader(负责人关系)、external_status(在职状态)。
11
+ // 这比方案「self 档不出」更严一点,换来的是**返回字段与 scope 无关**:消费模块不用按档位分支解析。
12
+ //
13
+ // self 档为什么不含兄弟部门:组织架构含层级 + 人名,比通讯录敏感得多;且同步会把「从未登录本站、
14
+ // 无从同意」的全员灌进本地。默认给最小集,真要看全的模块去申请权限点——这是需求方要付的代价。
15
+ //
16
+ // ⚡ includeSub 在 self 档只对「我所在部门」生效:祖先部门(也在可见范围内)一展开就是整棵子树,
17
+ // 等于绕过了自身路径的限制。宁可 400 也不静默截断——截断会给出「看似正确其实少人」的结果。
18
+ import type { PageResult } from "../infra/query";
19
+ import type { Department } from "./org";
20
+ import { assertPermission, assertTenantSelf } from "../infra/access";
21
+ import { pageArgs } from "../infra/query";
22
+ import { rateLimit } from "../infra/rate";
23
+ import { listDepartments, memberCounts } from "./org";
24
+
25
+ /** 全量读的权限点(D5):self 档不需要,scope=all 必须持有 */
26
+ export const PERM_ORG_READ_ALL = "platform.org.read.all";
27
+
28
+ export type OrgScope = "self" | "all";
29
+
30
+ export interface OrgReader {
31
+ uid: number
32
+ scope: OrgScope
33
+ /** self 档可见部门(我所在部门 + 祖先链);all 档为 null = 不限制 */
34
+ visible: Set<number> | null
35
+ /** self 档可展开子树的部门(只含我所在部门);all 档为 null */
36
+ expandable: Set<number> | null
37
+ }
38
+
39
+ export interface DeptItem {
40
+ id: number
41
+ parentId: number | null
42
+ name: string | null
43
+ nameEn: string | null
44
+ orderNo: number
45
+ memberCount: number
46
+ }
47
+
48
+ export interface MemberItem {
49
+ userId: number
50
+ username: string
51
+ displayName: string | null
52
+ deptId: number
53
+ /** 部门路径(根在前),部门名缺失时退 `#id` */
54
+ deptPath: string[]
55
+ isMain: boolean
56
+ }
57
+
58
+ export interface UserDepartments {
59
+ userId: number
60
+ username: string
61
+ displayName: string | null
62
+ position: string | null
63
+ mainDeptId: number | null
64
+ mainDeptPath: string[]
65
+ departments: Array<{ deptId: number, deptPath: string[] }>
66
+ }
67
+
68
+ export interface PickerResult {
69
+ departments: DeptItem[]
70
+ users: MemberItem[]
71
+ }
72
+
73
+ function fail(status: number, message: string): never {
74
+ throw Object.assign(new Error(message), { status });
75
+ }
76
+
77
+ export function parseScope(raw: unknown): OrgScope {
78
+ const s = String(raw ?? "").trim();
79
+ if (s === "" || s === "self")
80
+ return "self";
81
+ if (s === "all")
82
+ return "all";
83
+ fail(400, "scope must be 'self' or 'all'");
84
+ }
85
+
86
+ /**
87
+ * 解析调用者视角。self 档要现算可见集:我所在部门 → 沿 parent 链一路向上。
88
+ *
89
+ * 上溯**有界**(环在外部源调整期会短暂出现),且命中已收录节点即停——否则 A→B→A 会转满一圈。
90
+ */
91
+ export async function resolveReader(uid: number, scope: OrgScope): Promise<OrgReader> {
92
+ if (scope === "all")
93
+ return { uid, scope, visible: null, expandable: null };
94
+ const links = await db.table("sys_dept_member").select(["dept_id"]).where({ field: "user_id", op: "eq", value: uid }).all() as Array<Record<string, unknown>>;
95
+ const own = [...new Set(links.map(r => Number(r.dept_id)))];
96
+ // 没有部门 = 只见自己(visible 空集):不是「无限制」,别让空集被当成 all
97
+ if (!own.length)
98
+ return { uid, scope, visible: new Set(), expandable: new Set() };
99
+ const depts = await listDepartments();
100
+ const parentOf = new Map<number, number>();
101
+ for (const d of depts) {
102
+ if (d.parentId != null)
103
+ parentOf.set(d.id, d.parentId);
104
+ }
105
+ const visible = new Set<number>();
106
+ for (const start of own) {
107
+ let hop: number | null = start;
108
+ for (let i = 0; i <= depts.length && hop != null; i++) {
109
+ if (visible.has(hop))
110
+ break;
111
+ visible.add(hop);
112
+ hop = parentOf.get(hop) ?? null;
113
+ }
114
+ }
115
+ return { uid, scope, visible, expandable: new Set(own) };
116
+ }
117
+
118
+ /**
119
+ * 读面入口(7 个端点共用,避免守卫在 7 个 handler 里各抄一遍而慢慢长歪):
120
+ * 登录态 → 租户自洽 → 限速 → scope 解析 → all 档验权限点 → 算可见集。
121
+ *
122
+ * 顺序有讲究:**先 assertTenantSelf 再 rateLimit**——伪造 X-TENANT-ID 的人不该消耗
123
+ * (也不该污染)正常用户的限速窗口。
124
+ */
125
+ export async function resolveReadFace(scopeRaw: unknown): Promise<OrgReader> {
126
+ const uid = http.user?.id;
127
+ if (uid == null)
128
+ fail(401, "unauthorized");
129
+ await assertTenantSelf();
130
+ await rateLimit(`org:${Number(uid)}`, 60, 60_000);
131
+ const scope = parseScope(scopeRaw);
132
+ if (scope === "all")
133
+ await assertPermission(PERM_ORG_READ_ALL);
134
+ return resolveReader(Number(uid), scope);
135
+ }
136
+
137
+ function assertVisible(reader: OrgReader, deptId: number): void {
138
+ if (reader.visible && !reader.visible.has(deptId))
139
+ fail(403, "该部门不在可见范围内(scope=self 只能看自己所在部门及其上级)");
140
+ }
141
+
142
+ function assertExpandable(reader: OrgReader, deptId: number): void {
143
+ if (reader.expandable && !reader.expandable.has(deptId))
144
+ fail(400, "scope=self 只能展开自己所在部门的下级部门");
145
+ }
146
+
147
+ // ---- 部门索引(读面口径:只含未软删 + 同步可见的行;self 档再按 visible 收窄)----
148
+
149
+ interface DeptIndex {
150
+ depts: Department[]
151
+ byId: Map<number, Department>
152
+ children: Map<number, number[]>
153
+ counts: Map<number, number>
154
+ }
155
+
156
+ /**
157
+ * 一次取全量在内存里过滤/建树(同 org.ts::listDepartments 的取舍):部门量级是数百个节点,
158
+ * 而构造器没有递归 CTE,`in (全部 id)` 也不如内存过滤稳。真到虚拟树量级再分层取。
159
+ */
160
+ async function loadIndex(reader: OrgReader): Promise<DeptIndex> {
161
+ const all = await listDepartments();
162
+ // 先落到局部常量:闭包里直接读 `reader.visible` 会丢掉非空窄化(TS 不跨闭包保持)
163
+ const visible = reader.visible;
164
+ const depts = visible ? all.filter(d => visible.has(d.id)) : all;
165
+ const byId = new Map(depts.map(d => [d.id, d]));
166
+ const children = new Map<number, number[]>();
167
+ for (const d of depts) {
168
+ // 父不在索引里(被 self 档裁掉 / 父本身已删)→ 当根处理,父链到此为止
169
+ if (d.parentId == null || !byId.has(d.parentId))
170
+ continue;
171
+ (children.get(d.parentId) ?? children.set(d.parentId, []).get(d.parentId)!).push(d.id);
172
+ }
173
+ return { depts, byId, children, counts: await memberCounts() };
174
+ }
175
+
176
+ function toItem(d: Department, counts: Map<number, number>): DeptItem {
177
+ return {
178
+ id: d.id,
179
+ parentId: d.parentId,
180
+ name: d.name,
181
+ nameEn: d.nameEn,
182
+ orderNo: d.orderNo,
183
+ memberCount: counts.get(d.id) ?? 0,
184
+ };
185
+ }
186
+
187
+ /** 部门路径(根在前)。上溯有界:环不会死循环,只会走出一条带重复的短路径。 */
188
+ function pathOf(idx: DeptIndex, deptId: number): string[] {
189
+ const names: string[] = [];
190
+ let hop: number | null = deptId;
191
+ for (let i = 0; i <= idx.depts.length && hop != null; i++) {
192
+ const d = idx.byId.get(hop);
193
+ if (!d)
194
+ break;
195
+ names.unshift(d.name ?? `#${d.id}`);
196
+ hop = d.parentId;
197
+ }
198
+ return names;
199
+ }
200
+
201
+ /** 含自身的子树(BFS + seen,环安全) */
202
+ function subtreeOf(idx: DeptIndex, rootId: number): number[] {
203
+ const out: number[] = [];
204
+ const seen = new Set<number>([rootId]);
205
+ const stack: number[] = [rootId];
206
+ while (stack.length) {
207
+ const id = stack.pop()!;
208
+ out.push(id);
209
+ for (const c of idx.children.get(id) ?? []) {
210
+ if (seen.has(c))
211
+ continue;
212
+ seen.add(c);
213
+ stack.push(c);
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** 关键词红线(同通讯录口径:≥2 才给查,防拿单字把整份名册拖走) */
220
+ function keywordOf(raw: unknown): string | null {
221
+ const kw = String(raw ?? "").trim();
222
+ if (!kw)
223
+ return null;
224
+ if (kw.length < 2)
225
+ fail(400, "keyword too short (min 2)");
226
+ if (kw.length > 64)
227
+ fail(400, "keyword too long (max 64)");
228
+ return kw.toLowerCase();
229
+ }
230
+
231
+ // ---- 端点一:树 ----
232
+
233
+ /**
234
+ * GET /org/tree —— 部门平表(前端按 parentId 组装成树)。
235
+ *
236
+ * 为什么不出嵌套 children:oj 的契约 IR 不支持递归 schema(无 $ref),嵌套深度写不进契约;
237
+ * 与管理端 /api/system/departments 同一口径——树是**前端**的事,后端只给平表。
238
+ */
239
+ export async function readTree(reader: OrgReader): Promise<DeptItem[]> {
240
+ const idx = await loadIndex(reader);
241
+ return idx.depts.map(d => toItem(d, idx.counts));
242
+ }
243
+
244
+ // ---- 端点二:部门分页 ----
245
+
246
+ /** GET /org/departments —— 平铺分页(大企业避免一次拉全树)。parent=0 表示只取根层。 */
247
+ export async function pageDepartments(reader: OrgReader, opts: {
248
+ parent?: unknown
249
+ keyword?: unknown
250
+ page?: unknown
251
+ size?: unknown
252
+ } = {}): Promise<PageResult<DeptItem>> {
253
+ const idx = await loadIndex(reader);
254
+ const kw = keywordOf(opts.keyword);
255
+ let rows = idx.depts;
256
+ if (opts.parent != null && String(opts.parent).trim() !== "") {
257
+ const parent = Number(opts.parent);
258
+ rows = rows.filter(d => (parent === 0 ? d.parentId == null : d.parentId === parent));
259
+ }
260
+ if (kw)
261
+ rows = rows.filter(d => `${d.name ?? ""}\n${d.nameEn ?? ""}`.toLowerCase().includes(kw));
262
+ const { page, size, offset } = pageArgs(opts);
263
+ const list = rows.slice(offset, offset + size).map(d => toItem(d, idx.counts));
264
+ return { list, total: rows.length, page, size, truncated: offset + list.length < rows.length };
265
+ }
266
+
267
+ // ---- 端点三:成员分页 ----
268
+
269
+ /**
270
+ * 收集成员(分页与选人组件共用)。
271
+ *
272
+ * 两条过滤纪律:
273
+ * · 停用账号不出现(与通讯录同口径:is_forbidden=1 的人不该被当成收件人);
274
+ * · 一人多部门时**只出一条**(主部门优先,其次 dept_id 小的)——否则 includeSub 会把同一个人
275
+ * 按所属部门数重复吐出来,消费模块还得自己去重。
276
+ */
277
+ async function collectMembers(idx: DeptIndex, deptIds: number[] | null): Promise<MemberItem[]> {
278
+ if (deptIds && deptIds.length === 0)
279
+ return [];
280
+ const links = await db.table("sys_dept_member")
281
+ .select(["user_id", "dept_id", "is_main"])
282
+ .whereIf(deptIds, { field: "dept_id", op: "in", value: deptIds })
283
+ .all() as Array<{ user_id: number, dept_id: number, is_main: number }>;
284
+ if (!links.length)
285
+ return [];
286
+ const ids = [...new Set(links.map(l => Number(l.user_id)))];
287
+ const users = await db.table("users").select(["id", "username", "display_name"]).where({ field: "id", op: "in", value: ids }).where({ field: "is_forbidden", op: "eq", value: 0 }).all() as Array<{ id: number, username: string, display_name: string | null }>;
288
+ const byId = new Map(users.map(u => [Number(u.id), u]));
289
+ const ordered = [...links].sort((a, b) =>
290
+ Number(b.is_main ?? 0) - Number(a.is_main ?? 0) || Number(a.dept_id) - Number(b.dept_id));
291
+ const out: MemberItem[] = [];
292
+ const seen = new Set<number>();
293
+ for (const l of ordered) {
294
+ const deptId = Number(l.dept_id);
295
+ const userId = Number(l.user_id);
296
+ // 部门不在索引里(软删 / 失去可见范围 / 被 self 档裁掉)→ 整条不出,不暴露半个路径
297
+ if (!idx.byId.has(deptId) || seen.has(userId))
298
+ continue;
299
+ const u = byId.get(userId);
300
+ if (!u)
301
+ continue;
302
+ seen.add(userId);
303
+ out.push({
304
+ userId,
305
+ username: String(u.username),
306
+ displayName: u.display_name,
307
+ deptId,
308
+ deptPath: pathOf(idx, deptId),
309
+ isMain: Number(l.is_main ?? 0) === 1,
310
+ });
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * GET /org/members —— 部门成员(含部门路径)。
317
+ *
318
+ * `includeSub` 在 self 档只对「我所在部门」生效(见头注):对祖先部门请求展开会 400,
319
+ * 而不是静默截断成「只看得见的部分」。
320
+ */
321
+ export async function pageMembers(reader: OrgReader, opts: {
322
+ dept?: unknown
323
+ includeSub?: unknown
324
+ keyword?: unknown
325
+ page?: unknown
326
+ size?: unknown
327
+ } = {}): Promise<PageResult<MemberItem>> {
328
+ const idx = await loadIndex(reader);
329
+ let deptIds: number[] | null = null;
330
+ if (opts.dept != null && String(opts.dept).trim() !== "") {
331
+ const deptId = Number(opts.dept);
332
+ assertVisible(reader, deptId);
333
+ if (opts.includeSub === true || String(opts.includeSub) === "true" || String(opts.includeSub) === "1") {
334
+ assertExpandable(reader, deptId);
335
+ deptIds = subtreeOf(idx, deptId);
336
+ }
337
+ else {
338
+ deptIds = [deptId];
339
+ }
340
+ }
341
+ let rows = await collectMembers(idx, deptIds);
342
+ const kw = keywordOf(opts.keyword);
343
+ if (kw)
344
+ rows = rows.filter(m => `${m.username}\n${m.displayName ?? ""}`.toLowerCase().includes(kw));
345
+ const { page, size, offset } = pageArgs(opts);
346
+ const list = rows.slice(offset, offset + size);
347
+ return { list, total: rows.length, page, size, truncated: offset + list.length < rows.length };
348
+ }
349
+
350
+ // ---- 端点四:某人的部门 ----
351
+
352
+ /** GET /org/user —— 部门路径 / 主部门 / 职务。self 档只能查自己。 */
353
+ export async function readUserDepartments(reader: OrgReader, targetUid: number): Promise<UserDepartments> {
354
+ if (reader.scope === "self" && Number(targetUid) !== Number(reader.uid))
355
+ fail(403, "scope=self 只能查自己的部门信息");
356
+ const idx = await loadIndex(reader);
357
+ const rows = await db.table("users").select(["id", "username", "display_name", "position"]).where({ field: "id", op: "eq", value: targetUid }).all() as Array<Record<string, unknown>>;
358
+ const u = rows[0];
359
+ if (!u)
360
+ fail(404, "user not found");
361
+ const links = await db.table("sys_dept_member").select(["dept_id", "is_main"]).where({ field: "user_id", op: "eq", value: targetUid }).all() as Array<{ dept_id: number, is_main: number }>;
362
+ const depts = links
363
+ .filter(l => idx.byId.has(Number(l.dept_id)))
364
+ .sort((a, b) => Number(b.is_main ?? 0) - Number(a.is_main ?? 0) || Number(a.dept_id) - Number(b.dept_id))
365
+ .map(l => ({ deptId: Number(l.dept_id), deptPath: pathOf(idx, Number(l.dept_id)) }));
366
+ const mainRow = links.find(l => Number(l.is_main ?? 0) === 1 && idx.byId.has(Number(l.dept_id)));
367
+ return {
368
+ userId: Number(u.id),
369
+ username: String(u.username),
370
+ displayName: u.display_name == null ? null : String(u.display_name),
371
+ position: u.position == null ? null : String(u.position),
372
+ mainDeptId: mainRow ? Number(mainRow.dept_id) : null,
373
+ mainDeptPath: mainRow ? pathOf(idx, Number(mainRow.dept_id)) : [],
374
+ departments: depts,
375
+ };
376
+ }
377
+
378
+ // ---- 端点五:选人组件 ----
379
+
380
+ /** GET /org/picker —— 人 + 部门混合(选人组件一次拿全)。keyword 必填且 ≥2(同上防遍历)。 */
381
+ export async function readPicker(reader: OrgReader, opts: { keyword?: unknown, limit?: unknown } = {}): Promise<PickerResult> {
382
+ const kw = keywordOf(opts.keyword);
383
+ if (!kw)
384
+ fail(400, "keyword required");
385
+ const rawLimit = Number.parseInt(String(opts.limit ?? ""), 10);
386
+ const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(50, rawLimit) : 20;
387
+ const idx = await loadIndex(reader);
388
+ const departments = idx.depts
389
+ .filter(d => `${d.name ?? ""}\n${d.nameEn ?? ""}`.toLowerCase().includes(kw))
390
+ .slice(0, limit)
391
+ .map(d => toItem(d, idx.counts));
392
+ const users = (await collectMembers(idx, null))
393
+ .filter(m => `${m.username}\n${m.displayName ?? ""}`.toLowerCase().includes(kw))
394
+ .slice(0, limit);
395
+ return { departments, users };
396
+ }
397
+
398
+ // ---- 端点六 / 七:「算」而不是「看」(共享读面真正的价值)----
399
+
400
+ /**
401
+ * POST /org/resolve —— 把「部门(含子部门)」展开成 user_id 列表。
402
+ *
403
+ * 这是共享面真正的价值:缺了它,每个消费模块都会自己把树拉下来递归,共享名存实亡。
404
+ * 结果去重升序,方便调用方直接当收件人集合用。
405
+ */
406
+ export async function resolveUserIds(reader: OrgReader, input: {
407
+ deptIds?: unknown
408
+ userIds?: unknown
409
+ includeSub?: boolean
410
+ }): Promise<number[]> {
411
+ const idx = await loadIndex(reader);
412
+ const out = new Set<number>();
413
+ for (const raw of Array.isArray(input.userIds) ? input.userIds : []) {
414
+ const uid = Number(raw);
415
+ if (Number.isFinite(uid) && uid > 0)
416
+ out.add(uid);
417
+ }
418
+ const deptIds = (Array.isArray(input.deptIds) ? input.deptIds : []).map(Number).filter(n => Number.isFinite(n) && n > 0);
419
+ if (deptIds.length) {
420
+ const scope = new Set<number>();
421
+ for (const deptId of deptIds) {
422
+ assertVisible(reader, deptId);
423
+ if (input.includeSub) {
424
+ assertExpandable(reader, deptId);
425
+ for (const id of subtreeOf(idx, deptId))
426
+ scope.add(id);
427
+ }
428
+ else {
429
+ scope.add(deptId);
430
+ }
431
+ }
432
+ const list = [...scope];
433
+ if (list.length) {
434
+ const links = await db.table("sys_dept_member").select(["user_id"]).where({ field: "dept_id", op: "in", value: list }).all() as Array<{ user_id: number }>;
435
+ for (const l of links)
436
+ out.add(Number(l.user_id));
437
+ }
438
+ }
439
+ return [...out].sort((a, b) => a - b);
440
+ }
441
+
442
+ /** GET /org/is-member —— 某人是否在某部门(含子部门)下。self 档只能问自己。 */
443
+ export async function isMemberOf(reader: OrgReader, targetUid: number, deptId: number): Promise<boolean> {
444
+ if (reader.scope === "self" && Number(targetUid) !== Number(reader.uid))
445
+ fail(403, "scope=self 只能查自己是否属于某部门");
446
+ assertVisible(reader, deptId);
447
+ const idx = await loadIndex(reader);
448
+ const scope = new Set(subtreeOf(idx, deptId));
449
+ const links = await db.table("sys_dept_member").select(["dept_id"]).where({ field: "user_id", op: "eq", value: targetUid }).all() as Array<{ dept_id: number }>;
450
+ return links.some(l => scope.has(Number(l.dept_id)));
451
+ }