@oj-module/cli 0.2.47 → 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 (94) 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 +237 -14
  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/init.include.yaml +12 -0
  17. package/templates/openapi.json +2863 -549
  18. package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
  19. package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
  20. package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
  21. package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
  22. package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
  23. package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
  24. package/templates/src/_platform/api/manifest.yaml +3 -0
  25. package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
  26. package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
  27. package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
  28. package/templates/src/_platform/api/org/departments/api.ts +51 -0
  29. package/templates/src/_platform/api/org/is-member/api.ts +33 -0
  30. package/templates/src/_platform/api/org/members/api.ts +55 -0
  31. package/templates/src/_platform/api/org/picker/api.ts +59 -0
  32. package/templates/src/_platform/api/org/resolve/api.ts +38 -0
  33. package/templates/src/_platform/api/org/tree/api.ts +37 -0
  34. package/templates/src/_platform/api/org/user/api.ts +46 -0
  35. package/templates/src/_platform/api/schema.yaml +48 -0
  36. package/templates/src/_platform/api/seed.mysql.sql +10 -5
  37. package/templates/src/_platform/api/seed.pg.sql +10 -5
  38. package/templates/src/_platform/api/seed.sql +13 -5
  39. package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
  40. package/templates/src/_platform/web/__client/api.ts +170 -1
  41. package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
  42. package/templates/src/auth/api/_shared/session.ts +5 -1
  43. package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
  44. package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
  45. package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
  46. package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
  47. package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
  48. package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
  49. package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
  50. package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
  51. package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
  52. package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
  53. package/templates/src/auth/api/manifest.yaml +3 -0
  54. package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
  55. package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
  56. package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
  57. package/templates/src/auth/api/schema.yaml +31 -0
  58. package/templates/src/auth/api/wecom/admin/api.ts +3 -1
  59. package/templates/src/auth/api/wecom/api.ts +100 -18
  60. package/templates/src/auth/api/wecom/sync/api.ts +199 -0
  61. package/templates/src/auth/web/__client/api.schemas.ts +97 -0
  62. package/templates/src/auth/web/__client/api.ts +73 -1
  63. package/templates/src/notification/api/seed.sql +1 -1
  64. package/templates/src/system/api/departments/api.ts +142 -0
  65. package/templates/src/system/api/departments/detach/api.ts +34 -0
  66. package/templates/src/system/api/departments/members/api.ts +142 -0
  67. package/templates/src/system/api/departments/move/api.ts +39 -0
  68. package/templates/src/system/api/users/api.ts +45 -5
  69. package/templates/src/system/api/users/profile/api.ts +44 -0
  70. package/templates/src/system/web/__client/api.schemas.ts +143 -0
  71. package/templates/src/system/web/__client/api.ts +293 -1
  72. package/templates/src/system/web/entry.ts +6 -0
  73. package/templates/src/system/web/locales/en-US.json +112 -6
  74. package/templates/src/system/web/locales/zh-CN.json +112 -6
  75. package/templates/src/system/web/pages/departments/index.tsx +346 -0
  76. package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
  77. package/templates/src/system/web/pages/departments/tree.ts +66 -0
  78. package/templates/src/system/web/pages/errors.ts +1 -1
  79. package/templates/src/system/web/pages/users/index.tsx +49 -7
  80. package/templates/src/system/web/pages/wecom/index.tsx +14 -0
  81. package/templates/tests/l1-builder.test.ts +1 -1
  82. package/templates/tests/org-departments.spec.ts +311 -0
  83. package/templates/tests/org-read.spec.ts +469 -0
  84. package/templates/tests/rbac-contacts.spec.ts +13 -3
  85. package/templates/tests/rbac-seed.spec.ts +11 -0
  86. package/templates/tests/seed-role-accounts.spec.ts +6 -2
  87. package/templates/tests/wecom-admin.spec.ts +1 -0
  88. package/templates/tests/wecom-connector.spec.ts +115 -8
  89. package/templates/tests/wecom-crypt.spec.ts +12 -3
  90. package/templates/tests/wecom-endpoints.spec.ts +182 -14
  91. package/templates/tests/wecom-org-sync.spec.ts +598 -0
  92. package/templates/tests/wecom-sync.spec.ts +286 -0
  93. package/vendor/host-versions.json +3 -3
  94. package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
@@ -0,0 +1,566 @@
1
+ // 组织架构领域服务(0016:CRUD 基线 + 外部源适配器的落点)。
2
+ //
3
+ // 定位(方案 §14):**CRUD 是基线,企微 / LDAP 只是适配器**。所以本文件分成两半:
4
+ // · 上半(createDepartment / updateDepartment / deleteDepartment / moveDepartment / …)
5
+ // 是**管理端 CRUD**,带归属权校验;
6
+ // · 下半(upsertDepartment / upsertMember / markSeen / reconcileHidden)
7
+ // 是**适配器专用**写路径,不做归属权校验(适配器就是该 origin 的拥有者)。
8
+ //
9
+ // 归属权(D13):`origin` 不只是来源标签,它标识「谁拥有这一行」。
10
+ // local = 本站管理员全权;wecom / ldap / sso = 对应适配器管辖。
11
+ // 任何同步与回收动作都只作用于自己 origin 的行——这是「适配器可插拔」与「CRUD 保底」
12
+ // 能共存的根本机制(否则本地建的部门会在第一轮同步后集体消失)。
13
+ //
14
+ // 构造器纪律:oj 的 SQL 构造器**没有 upsert**(sql-builder-guide §2 能力矩阵无 ON CONFLICT
15
+ // DO UPDATE),故写入统一「先 select → 有则 update / 无则 insert → 撞唯一约束再 update 回查」
16
+ // (照 store.ts::saveProfile 的先例)。另一条:三方言 affected-rows 不一致,
17
+ // 「删/改了几行」**不可**用来判成败(SKILL.md),只能靠唯一约束与事务保证幂等。
18
+ import { recordOp } from "../infra/audit";
19
+
20
+ /** 本站自建的来源标识:管理员全权、适配器不碰 */
21
+ export const ORIGIN_LOCAL = "local";
22
+
23
+ export interface OrgDeps {
24
+ now: () => number
25
+ }
26
+
27
+ export interface Department {
28
+ id: number
29
+ origin: string
30
+ originId: string
31
+ parentId: number | null
32
+ name: string | null
33
+ nameEn: string | null
34
+ orderNo: number
35
+ syncVisible: boolean
36
+ deleted: boolean
37
+ updateTime: number
38
+ }
39
+
40
+ export interface DeptNode extends Omit<Department, "syncVisible" | "deleted" | "updateTime"> {
41
+ name: string | null
42
+ memberCount: number
43
+ children: DeptNode[]
44
+ }
45
+
46
+ export interface DeptMember {
47
+ userId: number
48
+ deptId: number
49
+ orderNo: number
50
+ isLeader: boolean
51
+ isMain: boolean
52
+ username: string
53
+ displayName: string | null
54
+ }
55
+
56
+ function fail(status: number, message: string): never {
57
+ throw Object.assign(new Error(message), { status });
58
+ }
59
+
60
+ /** 来源 → 人话(错误提示要能直接给用户看) */
61
+ export function originLabel(origin: string): string {
62
+ switch (origin) {
63
+ case "wecom": return "企业微信";
64
+ case "ldap": return "LDAP";
65
+ case "sso": return "SSO";
66
+ case "local": return "本站";
67
+ default: return origin;
68
+ }
69
+ }
70
+
71
+ /** D13:结构字段(名称/层级/删除)只归 origin='local' 的行;其余必须先「转为本地维护」(D14) */
72
+ function assertLocal(dept: Department, action: string): void {
73
+ if (dept.origin === ORIGIN_LOCAL)
74
+ return;
75
+ fail(400, `该部门由${originLabel(dept.origin)}同步维护,不能${action};如需本站自行维护,请先执行「转为本地维护」`);
76
+ }
77
+
78
+ type DeptRow = Record<string, unknown>;
79
+
80
+ function toDepartment(r: DeptRow): Department {
81
+ return {
82
+ id: Number(r.id),
83
+ origin: String(r.origin),
84
+ originId: String(r.origin_id),
85
+ parentId: r.parent_id == null ? null : Number(r.parent_id),
86
+ name: r.name == null ? null : String(r.name),
87
+ nameEn: r.name_en == null ? null : String(r.name_en),
88
+ orderNo: Number(r.order_no ?? 0),
89
+ syncVisible: Number(r.sync_visible ?? 1) === 1,
90
+ deleted: Number(r.deleted ?? 0) === 1,
91
+ updateTime: Number(r.update_time ?? 0),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * 部门列表(一次取全量,供内存建树用)。
97
+ *
98
+ * 为什么不分页:构造器**没有递归 CTE**,而部门树的典型量级(数百个节点)一次取回在内存里
99
+ * 组装远快于逐层查询;`includeSub` 这类查询也走内存闭包(方案 §3 C10)。
100
+ * 真到了需要虚拟树/分页的量级,再按 parent_id 分层取(届时应先压测,别提前优化)。
101
+ */
102
+ export async function listDepartments(opts: { includeDeleted?: boolean, includeHidden?: boolean } = {}): Promise<Department[]> {
103
+ let q = db.table("sys_department").select(["id", "origin", "origin_id", "parent_id", "name", "name_en", "order_no", "sync_visible", "deleted", "update_time"]);
104
+ if (!opts.includeDeleted)
105
+ q = q.where({ field: "deleted", op: "eq", value: 0 });
106
+ if (!opts.includeHidden)
107
+ q = q.where({ field: "sync_visible", op: "eq", value: 1 });
108
+ // order_no「值大靠前」,同值按 id 稳定排序(避免同层顺序在刷新间跳动)
109
+ const rows = await q.orderBy([{ field: "order_no", dir: "desc" }, { field: "id", dir: "asc" }]).all() as DeptRow[];
110
+ return rows.map(toDepartment);
111
+ }
112
+
113
+ export async function findDepartment(id: number): Promise<Department> {
114
+ const rows = await db.table("sys_department").select(["id", "origin", "origin_id", "parent_id", "name", "name_en", "order_no", "sync_visible", "deleted", "update_time"]).where({ field: "id", op: "eq", value: id }).all() as DeptRow[];
115
+ if (!rows[0])
116
+ fail(404, "department not found");
117
+ return toDepartment(rows[0]!);
118
+ }
119
+
120
+ /**
121
+ * 组装部门树(含成员计数)。
122
+ *
123
+ * 两个必须处理的脏数据(都是可见范围裁剪的必然产物,不是 bug):
124
+ * · **孤儿**:parent_id 指向一个不存在/已删的行 → 提升到根;
125
+ * · **环**:A→B→A(外部源结构调整期间可能短暂出现)→ 断环并提升到根,绝不死循环。
126
+ */
127
+ export async function buildTree(): Promise<{ roots: DeptNode[], orphans: number, cycles: number }> {
128
+ const depts = await listDepartments();
129
+ const alive = new Set(depts.map(d => d.id));
130
+ const counts = await memberCounts();
131
+ const nodes = new Map<number, DeptNode>();
132
+ for (const d of depts) {
133
+ nodes.set(d.id, {
134
+ id: d.id,
135
+ origin: d.origin,
136
+ originId: d.originId,
137
+ parentId: d.parentId,
138
+ name: d.name,
139
+ nameEn: d.nameEn,
140
+ orderNo: d.orderNo,
141
+ memberCount: counts.get(d.id) ?? 0,
142
+ children: [],
143
+ });
144
+ }
145
+ let orphans = 0;
146
+ let cycles = 0;
147
+ const roots: DeptNode[] = [];
148
+ for (const d of depts) {
149
+ const node = nodes.get(d.id)!;
150
+ const parentId = d.parentId;
151
+ if (parentId == null || !alive.has(parentId) || parentId === d.id) {
152
+ if (parentId != null && alive.has(parentId))
153
+ cycles++;
154
+ else if (parentId != null)
155
+ orphans++;
156
+ roots.push(node);
157
+ continue;
158
+ }
159
+ // 沿链上溯判环:任一祖先回到自己即断环
160
+ let hop: number | null = parentId;
161
+ let cyclic = false;
162
+ for (let i = 0; i < nodes.size + 1 && hop != null; i++) {
163
+ if (hop === d.id) {
164
+ cyclic = true;
165
+ break;
166
+ }
167
+ const p = nodes.get(hop);
168
+ hop = p ? p.parentId : null;
169
+ }
170
+ if (cyclic) {
171
+ cycles++;
172
+ roots.push(node);
173
+ continue;
174
+ }
175
+ nodes.get(parentId)!.children.push(node);
176
+ }
177
+ return { roots, orphans, cycles };
178
+ }
179
+
180
+ /** 每部门的成员数(一次聚合,避免 N+1 查询) */
181
+ export async function memberCounts(): Promise<Map<number, number>> {
182
+ const rows = await db.table("sys_dept_member").select([{ fn: "count", as: "c" }, "dept_id"]).groupBy(["dept_id"]).all() as Array<{ c: number, dept_id: number }>;
183
+ return new Map(rows.map(r => [Number(r.dept_id), Number(r.c)]));
184
+ }
185
+
186
+ /** 建部门(基线 CRUD)。origin 恒为 local——外部源的部门一律走 upsertDepartment。 */
187
+ export async function createDepartment(input: { name: string, parentId?: number | null, nameEn?: string | null, orderNo?: number }, deps: OrgDeps): Promise<number> {
188
+ const name = String(input.name ?? "").trim();
189
+ if (!name)
190
+ fail(400, "department name required");
191
+ const parentId = input.parentId == null ? null : Number(input.parentId);
192
+ if (parentId != null)
193
+ await findDepartment(parentId); // 父不存在直接 404,别建出孤儿
194
+ const rows = await db.table("sys_department").insert({
195
+ origin: ORIGIN_LOCAL,
196
+ origin_id: localOriginId(),
197
+ parent_id: parentId,
198
+ name,
199
+ name_en: input.nameEn ?? null,
200
+ order_no: Number(input.orderNo ?? 0),
201
+ sync_visible: 1,
202
+ deleted: 0,
203
+ update_time: deps.now(),
204
+ }).returning(["id"]).run() as Array<{ id: number }>;
205
+ const id = Number(rows[0]!.id);
206
+ await recordOp({ action: "org.department.create", module: "framework", target: name, detail: { id, parentId } });
207
+ return id;
208
+ }
209
+
210
+ /**
211
+ * 本地部门没有外部主键,但唯一约束是 (tenant_id, origin, origin_id),
212
+ * 所以给它一个生成出来的稳定串(不复用自增 id:那要「先插后改」,且跨库导出会错乱)。
213
+ */
214
+ function localOriginId(): string {
215
+ // 用 getRandomValues 而不是 randomHex:前者在 oj 运行时与 Node 两侧都有
216
+ // (global.d.ts 声明 + Node 原生 `crypto`),后端 BDD 夹具没有 oj 的 crypto 增补,
217
+ // 用 randomHex 会让这条路径在测试里直接 TypeError。
218
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
219
+ let hex = "";
220
+ for (const b of bytes)
221
+ hex += b.toString(16).padStart(2, "0");
222
+ return `l${hex}`;
223
+ }
224
+
225
+ /** 改部门(基线 CRUD)。按 D13 只放行 origin='local'。 */
226
+ export async function updateDepartment(id: number, patch: { name?: string, nameEn?: string | null, orderNo?: number }, deps: OrgDeps): Promise<void> {
227
+ const dept = await findDepartment(id);
228
+ assertLocal(dept, "修改");
229
+ const cols: Record<string, unknown> = { update_time: deps.now() };
230
+ if (patch.name !== undefined) {
231
+ const name = String(patch.name).trim();
232
+ if (!name)
233
+ fail(400, "department name required");
234
+ cols.name = name;
235
+ }
236
+ if (patch.nameEn !== undefined)
237
+ cols.name_en = patch.nameEn;
238
+ if (patch.orderNo !== undefined)
239
+ cols.order_no = Number(patch.orderNo);
240
+ await db.table("sys_department").update(cols).where({ field: "id", op: "eq", value: id }).run();
241
+ await recordOp({ action: "org.department.update", module: "framework", target: String(cols.name ?? dept.name ?? `#${id}`), detail: { id } });
242
+ }
243
+
244
+ /**
245
+ * 删部门(基线 CRUD)。按 D13 只放行 origin='local'。
246
+ *
247
+ * 有子部门时**拒绝**而不是级联删:级联删是不可逆的破坏性动作,而「先移走或先删子部门」
248
+ * 的代价很低。真要批量删,由调用方自己按树自底向上删。
249
+ */
250
+ export async function deleteDepartment(id: number): Promise<void> {
251
+ const dept = await findDepartment(id);
252
+ assertLocal(dept, "删除");
253
+ const children = await db.table("sys_department").select(["id"]).where({ field: "parent_id", op: "eq", value: id }).where({ field: "deleted", op: "eq", value: 0 }).all();
254
+ if (children.length)
255
+ fail(400, "该部门下还有子部门,请先移除或删除子部门");
256
+ await db.tx(async (t) => {
257
+ // 逻辑外键无 FK 约束,关联必须手动清(否则留下指向已删部门的悬空行)
258
+ await t.table("sys_dept_member").where({ field: "dept_id", op: "eq", value: id }).delete().run();
259
+ await t.table("sys_department").where({ field: "id", op: "eq", value: id }).delete().run();
260
+ });
261
+ await recordOp({ action: "org.department.delete", module: "framework", target: String(dept.name ?? `#${id}`), detail: { id } });
262
+ }
263
+
264
+ /** 移动部门(改父级 + 排序)。按 D13 只放行 origin='local'。 */
265
+ export async function moveDepartment(id: number, parentId: number | null, deps: OrgDeps): Promise<void> {
266
+ const dept = await findDepartment(id);
267
+ assertLocal(dept, "移动");
268
+ const target = parentId == null ? null : Number(parentId);
269
+ if (target != null) {
270
+ if (target === id)
271
+ fail(400, "cannot move a department into itself");
272
+ // 环检测:**沿 target 向上**走父链,撞到 id 即成环。
273
+ //
274
+ // 方向别搞反:把 id 挂到 target 下形成的环是 `id → target → … → id`,
275
+ // 也就是「id 是 target 的祖先」。所以要从 target 往上找 id,不是从 target
276
+ // 往下找(往下找只会漏判——实测把父部门挂到自己的孙部门下被放行,树直接成环)。
277
+ const all = await listDepartments({ includeDeleted: true, includeHidden: true });
278
+ const parentOf = new Map<number, number>();
279
+ for (const d of all) {
280
+ if (d.parentId != null)
281
+ parentOf.set(d.id, d.parentId);
282
+ }
283
+ let hop: number | null = target;
284
+ for (let i = 0; i <= all.length && hop != null; i++) {
285
+ if (hop === id)
286
+ fail(400, "cannot move a department into its own descendant");
287
+ hop = parentOf.get(hop) ?? null;
288
+ }
289
+ }
290
+ await db.table("sys_department").update({ parent_id: target, update_time: deps.now() }).where({ field: "id", op: "eq", value: id }).run();
291
+ await recordOp({ action: "org.department.move", module: "framework", target: String(dept.name ?? `#${id}`), detail: { id, parentId: target } });
292
+ }
293
+
294
+ /**
295
+ * D14 保底出口:把一行脱离适配器管辖(origin → local),此后 CRUD 全开、同步不再改写它。
296
+ *
297
+ * 反向操作(转回外部源)**不提供**:转回去后下一次同步会立刻按源口径改写,语义不可控。
298
+ */
299
+ export async function detachToLocal(id: number, deps: OrgDeps): Promise<void> {
300
+ const dept = await findDepartment(id);
301
+ if (dept.origin === ORIGIN_LOCAL)
302
+ return;
303
+ await db.table("sys_department").update({ origin: ORIGIN_LOCAL, update_time: deps.now() }).where({ field: "id", op: "eq", value: id }).run();
304
+ await recordOp({ action: "org.department.detach", module: "framework", target: String(dept.name ?? `#${id}`), detail: { id, from: dept.origin } });
305
+ }
306
+
307
+ /**
308
+ * 部门成员(含 users 的展示字段)。
309
+ * 分两次查而不是 join:构造器的 `join ... ON` 带值条件有已知缺口,而 in 查询足够快。
310
+ */
311
+ export async function listMembers(deptId: number): Promise<DeptMember[]> {
312
+ const links = await db.table("sys_dept_member").select(["user_id", "dept_id", "order_no", "is_leader", "is_main"]).where({ field: "dept_id", op: "eq", value: deptId }).orderBy([{ field: "order_no", dir: "desc" }, { field: "user_id", dir: "asc" }]).all() as Array<Record<string, unknown>>;
313
+ if (!links.length)
314
+ return [];
315
+ const ids = links.map(r => Number(r.user_id));
316
+ const users = await db.table("users").select(["id", "username", "display_name"]).where({ field: "id", op: "in", value: ids }).all() as Array<{ id: number, username: string, display_name: string | null }>;
317
+ const byId = new Map(users.map(u => [Number(u.id), u]));
318
+ return links.map((r) => {
319
+ const u = byId.get(Number(r.user_id));
320
+ return {
321
+ userId: Number(r.user_id),
322
+ deptId: Number(r.dept_id),
323
+ orderNo: Number(r.order_no ?? 0),
324
+ isLeader: Number(r.is_leader ?? 0) === 1,
325
+ isMain: Number(r.is_main ?? 0) === 1,
326
+ username: u ? String(u.username) : "",
327
+ displayName: u ? u.display_name : null,
328
+ };
329
+ });
330
+ }
331
+
332
+ /** 加人进部门(基线 CRUD)。部门的 origin 不限制加人——「谁在这个部门」本站始终可调。 */
333
+ export async function addMember(deptId: number, userId: number, opts: { isLeader?: boolean, isMain?: boolean, orderNo?: number } = {}, deps: OrgDeps): Promise<void> {
334
+ await findDepartment(deptId);
335
+ const exists = await db.table("sys_dept_member").select(["id"]).where({ and: [
336
+ { field: "user_id", op: "eq", value: userId },
337
+ { field: "dept_id", op: "eq", value: deptId },
338
+ ] }).all();
339
+ if (exists.length)
340
+ fail(400, "user already in this department");
341
+ try {
342
+ await db.table("sys_dept_member").insert({
343
+ user_id: userId,
344
+ dept_id: deptId,
345
+ order_no: Number(opts.orderNo ?? 0),
346
+ is_leader: opts.isLeader ? 1 : 0,
347
+ is_main: opts.isMain ? 1 : 0,
348
+ update_time: deps.now(),
349
+ }).run();
350
+ }
351
+ catch (e) {
352
+ // 并发插入者先落 → 视为已存在(同 saveProfile / ensureWecomUser 的先例)
353
+ if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
354
+ throw e;
355
+ }
356
+ if (opts.isMain)
357
+ await setMainDepartment(userId, deptId, deps);
358
+ await recordOp({ action: "org.member.add", module: "framework", target: `#${userId}`, detail: { deptId } });
359
+ }
360
+
361
+ /** 改成员属性(基线 CRUD):只改已存在的关联行,不存在即 404(与 upsertMember 的「无则建」相反) */
362
+ export async function updateMember(deptId: number, userId: number, patch: { isLeader?: boolean, isMain?: boolean, orderNo?: number }, deps: OrgDeps): Promise<void> {
363
+ const scope = { and: [
364
+ { field: "user_id", op: "eq", value: userId },
365
+ { field: "dept_id", op: "eq", value: deptId },
366
+ ] };
367
+ const rows = await db.table("sys_dept_member").select(["id"]).where(scope).all();
368
+ if (!rows.length)
369
+ fail(404, "user is not in this department");
370
+ const cols: Record<string, unknown> = { update_time: deps.now() };
371
+ if (patch.isLeader !== undefined)
372
+ cols.is_leader = patch.isLeader ? 1 : 0;
373
+ if (patch.orderNo !== undefined)
374
+ cols.order_no = Number(patch.orderNo);
375
+ await db.table("sys_dept_member").update(cols).where(scope).run();
376
+ if (patch.isMain)
377
+ await setMainDepartment(userId, deptId, deps);
378
+ await recordOp({ action: "org.member.update", module: "framework", target: `#${userId}`, detail: { deptId, ...patch } });
379
+ }
380
+
381
+ /** 移出部门(基线 CRUD) */
382
+ export async function removeMember(deptId: number, userId: number, deps: OrgDeps): Promise<void> {
383
+ await db.table("sys_dept_member").where({ and: [
384
+ { field: "user_id", op: "eq", value: userId },
385
+ { field: "dept_id", op: "eq", value: deptId },
386
+ ] }).delete().run();
387
+ await recordOp({ action: "org.member.remove", module: "framework", target: `#${userId}`, detail: { deptId, updateTime: deps.now() } });
388
+ }
389
+
390
+ /** 设主部门:同一用户在全租户内至多一行 is_main=1(企微 main_department 缺失时由调用方兜底) */
391
+ export async function setMainDepartment(userId: number, deptId: number, deps: OrgDeps): Promise<void> {
392
+ await db.tx(async (t) => {
393
+ await t.table("sys_dept_member").update({ is_main: 0, update_time: deps.now() }).where({ field: "user_id", op: "eq", value: userId }).run();
394
+ await t.table("sys_dept_member").update({ is_main: 1, update_time: deps.now() }).where({ and: [
395
+ { field: "user_id", op: "eq", value: userId },
396
+ { field: "dept_id", op: "eq", value: deptId },
397
+ ] }).run();
398
+ });
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // 适配器专用写路径(企微 / LDAP / …):不做归属权校验——适配器就是该 origin 的拥有者
403
+ // ---------------------------------------------------------------------------
404
+
405
+ /**
406
+ * 按 (origin, origin_id) 幂等写入部门,返回本地 id。
407
+ *
408
+ * 只更新**结构字段**(name / parent / order)。刻意**不改** sync_visible、deleted、origin:
409
+ * 前两者由 reconcile 与删除事件维护,后者只有「转为本地维护」(D14) 能改。
410
+ */
411
+ export async function upsertDepartment(input: {
412
+ origin: string
413
+ originId: string
414
+ parentId: number | null
415
+ name: string | null
416
+ orderNo?: number
417
+ }, deps: OrgDeps): Promise<number> {
418
+ const scope = { and: [
419
+ { field: "origin", op: "eq", value: input.origin },
420
+ { field: "origin_id", op: "eq", value: input.originId },
421
+ ] };
422
+ const cols = {
423
+ parent_id: input.parentId,
424
+ name: input.name,
425
+ order_no: Number(input.orderNo ?? 0),
426
+ update_time: deps.now(),
427
+ };
428
+ const rows = await db.table("sys_department").select(["id"]).where(scope).all() as Array<{ id: number }>;
429
+ if (rows.length) {
430
+ await db.table("sys_department").update(cols).where(scope).run();
431
+ return Number(rows[0]!.id);
432
+ }
433
+ try {
434
+ const inserted = await db.table("sys_department").insert({
435
+ origin: input.origin,
436
+ origin_id: input.originId,
437
+ sync_visible: 1,
438
+ deleted: 0,
439
+ ...cols,
440
+ }).returning(["id"]).run() as Array<{ id: number }>;
441
+ return Number(inserted[0]!.id);
442
+ }
443
+ catch (e) {
444
+ // 并发插入者先落:转为更新并回查(同 saveProfile 的先例)
445
+ if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
446
+ throw e;
447
+ await db.table("sys_department").update(cols).where(scope).run();
448
+ const again = await db.table("sys_department").select(["id"]).where(scope).all() as Array<{ id: number }>;
449
+ return Number(again[0]!.id);
450
+ }
451
+ }
452
+
453
+ /** 按 (user_id, dept_id) 幂等写入成员关联 */
454
+ export async function upsertMember(input: {
455
+ userId: number
456
+ deptId: number
457
+ orderNo?: number
458
+ isLeader?: boolean
459
+ isMain?: boolean
460
+ }, deps: OrgDeps): Promise<void> {
461
+ const scope = { and: [
462
+ { field: "user_id", op: "eq", value: input.userId },
463
+ { field: "dept_id", op: "eq", value: input.deptId },
464
+ ] };
465
+ const cols = {
466
+ order_no: Number(input.orderNo ?? 0),
467
+ is_leader: input.isLeader ? 1 : 0,
468
+ is_main: input.isMain ? 1 : 0,
469
+ update_time: deps.now(),
470
+ };
471
+ const rows = await db.table("sys_dept_member").select(["id"]).where(scope).all();
472
+ if (rows.length) {
473
+ await db.table("sys_dept_member").update(cols).where(scope).run();
474
+ return;
475
+ }
476
+ try {
477
+ await db.table("sys_dept_member").insert({ user_id: input.userId, dept_id: input.deptId, ...cols }).run();
478
+ }
479
+ catch (e) {
480
+ if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
481
+ throw e;
482
+ await db.table("sys_dept_member").update(cols).where(scope).run();
483
+ }
484
+ }
485
+
486
+ /**
487
+ * 标记「本轮同步命中」(墓碑回收的正面证据)。
488
+ * 只作用于**指定 origin**——漏了这个条件,本地建的部门会被第一轮同步集体判死(D13)。
489
+ *
490
+ * 顺带把 `sync_visible` 拉回 1:上一轮因可见范围收窄被判隐的部门,这一轮又出现了,
491
+ * 只写 `seen_job_id` 会让它永远隐身(reconcile 只置 0,没有任何地方置回 1)。
492
+ */
493
+ export async function markSeen(origin: string, deptIds: number[], jobId: number, deps: OrgDeps): Promise<void> {
494
+ if (!deptIds.length)
495
+ return;
496
+ await db.table("sys_department").update({ seen_job_id: jobId, sync_visible: 1, update_time: deps.now() }).where({ and: [
497
+ { field: "origin", op: "eq", value: origin },
498
+ { field: "id", op: "in", value: deptIds },
499
+ ] }).run();
500
+ }
501
+
502
+ /**
503
+ * 墓碑回收:把「上一轮见过、这一轮没见」的部门置为不可见。
504
+ *
505
+ * 两条硬纪律:
506
+ * 1. **只作用于指定 origin**(D13);
507
+ * 2. **只在调用方确认本轮完整(无 skip)后调用**——否则「上游抖了一下」会被误判成
508
+ * 「这些部门被删了」,表现是组织架构大面积消失(D9)。调用方负责判断。
509
+ * 另:`deleted` 只由外部源的删除事件置(D9),这里不动它。
510
+ */
511
+ export async function reconcileHidden(origin: string, jobId: number, deps: OrgDeps): Promise<number> {
512
+ return Number(await db.table("sys_department").update({ sync_visible: 0, update_time: deps.now() }).where({ and: [
513
+ { field: "origin", op: "eq", value: origin },
514
+ { field: "sync_visible", op: "eq", value: 1 },
515
+ { field: "seen_job_id", op: "lt", value: jobId },
516
+ ] }).run());
517
+ }
518
+
519
+ /**
520
+ * 外部源侧的部门删除事件:置 `deleted=1`(软删,保留壳以防成员关联悬挂),**级联到整棵子树**。
521
+ *
522
+ * 为什么级联:企微的 `delete_party` 只报被删的那一个 id。可见范围之外的子部门未必各自收到通知,
523
+ * 不在这里一并标记,子树就会挂在一个已删的父节点上——列表按 `deleted=0` 过滤后它们变成孤儿,
524
+ * 只能在 `buildTree` 里靠「父不在 → 提根」补救,而那时已经看不出它们本该消失。
525
+ *
526
+ * 这也是 `deleted=1` 的**唯一**写入点:`reconcileHidden` 只动 `sync_visible`(可见范围收窄,
527
+ * 不是删除)——两者混用会把「看不见」和「没了」变成同一件事。
528
+ */
529
+ export async function markDeleted(origin: string, originId: string, deps: OrgDeps): Promise<number> {
530
+ const rows = await db.table("sys_department").select(["id", "origin_id", "parent_id"]).where({ field: "origin", op: "eq", value: origin }).all() as Array<Record<string, unknown>>;
531
+ const rootId = rows.find(r => String(r.origin_id) === originId)?.id;
532
+ if (rootId == null)
533
+ return 0;
534
+ const childrenOf = new Map<number, number[]>();
535
+ for (const r of rows) {
536
+ if (r.parent_id == null)
537
+ continue;
538
+ const pid = Number(r.parent_id);
539
+ const list = childrenOf.get(pid);
540
+ if (list)
541
+ list.push(Number(r.id));
542
+ else
543
+ childrenOf.set(pid, [Number(r.id)]);
544
+ }
545
+ // 广度优先收集子树。不加 visited 也不会死循环(真成环时每个节点只会被推一次),
546
+ // 但结构脏到成环时不该再信任 parent_id,故仍按集合去重。
547
+ const ids: number[] = [];
548
+ const seen = new Set<number>();
549
+ const queue = [Number(rootId)];
550
+ while (queue.length) {
551
+ const id = queue.shift()!;
552
+ if (seen.has(id))
553
+ continue;
554
+ seen.add(id);
555
+ ids.push(id);
556
+ for (const child of childrenOf.get(id) ?? [])
557
+ queue.push(child);
558
+ }
559
+ await db.table("sys_department").update({ deleted: 1, sync_visible: 0, update_time: deps.now() }).where({ and: [
560
+ { field: "origin", op: "eq", value: origin },
561
+ { field: "id", op: "in", value: ids },
562
+ ] }).run();
563
+ // 返回「命中的子树节点数」而不是 affected-rows:三方言 affected-rows 口径不一致,
564
+ // 且已删的行再删一次可能计 0——调用方要的是「标了几个」,不是「改了几行」。
565
+ return ids.length;
566
+ }