@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.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +237 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -31
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +115 -8
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -10,11 +10,22 @@ export interface UserDeps {
|
|
|
10
10
|
bcryptHash: (pw: string) => Promise<string>
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** 用户所属的一个部门。带 id 是为了前端能按部门(含子部门)筛选——部门名会重名。 */
|
|
14
|
+
export interface UserDeptRef {
|
|
15
|
+
id: number
|
|
16
|
+
/** 部门名(软删/未命名部门为 null,展示口径交给调用方) */
|
|
17
|
+
name: string | null
|
|
18
|
+
isMain: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export interface UserRow {
|
|
14
22
|
id: number
|
|
15
23
|
username: string
|
|
16
24
|
display_name: string | null
|
|
17
25
|
email: string | null
|
|
26
|
+
position: string | null
|
|
27
|
+
/** 所属部门(主部门在前) */
|
|
28
|
+
departments: UserDeptRef[]
|
|
18
29
|
is_forbidden: boolean
|
|
19
30
|
must_change: boolean
|
|
20
31
|
roles: string[]
|
|
@@ -58,22 +69,43 @@ async function assertMembershipAllowed(actorId: number, nextRoles: string[]): Pr
|
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export async function listUsers(): Promise<UserRow[]> {
|
|
61
|
-
const rows = await db.table("users").select(["id", "username", "display_name", "email", "is_forbidden", "must_change"]).orderBy([{ field: "username" }]).all() as Array<Record<string, unknown>>;
|
|
72
|
+
const rows = await db.table("users").select(["id", "username", "display_name", "email", "position", "is_forbidden", "must_change"]).orderBy([{ field: "username" }]).all() as Array<Record<string, unknown>>;
|
|
62
73
|
const roleRows = await db.table("sys_user_role").select(["user_id", "role_name"]).all() as Array<{ user_id: number, role_name: string }>;
|
|
63
74
|
const roleMap = new Map<number, string[]>();
|
|
64
75
|
for (const r of roleRows)
|
|
65
76
|
(roleMap.get(Number(r.user_id)) ?? roleMap.set(Number(r.user_id), []).get(Number(r.user_id)))!.push(r.role_name);
|
|
77
|
+
const depts = await departmentsByUser();
|
|
66
78
|
return rows.map(r => ({
|
|
67
79
|
id: Number(r.id),
|
|
68
80
|
username: String(r.username),
|
|
69
81
|
display_name: r.display_name == null ? null : String(r.display_name),
|
|
70
82
|
email: r.email == null ? null : String(r.email),
|
|
83
|
+
position: r.position == null ? null : String(r.position),
|
|
84
|
+
departments: depts.get(Number(r.id)) ?? [],
|
|
71
85
|
is_forbidden: Number(r.is_forbidden) === 1,
|
|
72
86
|
must_change: Number(r.must_change) === 1,
|
|
73
87
|
roles: roleMap.get(Number(r.id)) ?? [],
|
|
74
88
|
}));
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
/** uid → 所属部门(主部门排前)。两次查询而非 join:构造器 join 带值条件有已知缺口。 */
|
|
92
|
+
async function departmentsByUser(): Promise<Map<number, UserDeptRef[]>> {
|
|
93
|
+
const memberRows = await db.table("sys_dept_member").select(["user_id", "dept_id", "is_main"]).all() as Array<{ user_id: number, dept_id: number, is_main: number }>;
|
|
94
|
+
if (!memberRows.length)
|
|
95
|
+
return new Map();
|
|
96
|
+
const deptRows = await db.table("sys_department").select(["id", "name"]).where({ field: "deleted", op: "eq", value: 0 }).all() as Array<{ id: number, name: string | null }>;
|
|
97
|
+
const nameOf = new Map(deptRows.map(d => [Number(d.id), d.name == null ? null : String(d.name)]));
|
|
98
|
+
const out = new Map<number, UserDeptRef[]>();
|
|
99
|
+
// 主部门先落:is_main=1 的行先排一遍,其余后追加
|
|
100
|
+
const ordered = [...memberRows].sort((a, b) => Number(b.is_main ?? 0) - Number(a.is_main ?? 0));
|
|
101
|
+
for (const m of ordered) {
|
|
102
|
+
const uid = Number(m.user_id);
|
|
103
|
+
const id = Number(m.dept_id);
|
|
104
|
+
(out.get(uid) ?? out.set(uid, []).get(uid)!).push({ id, name: nameOf.get(id) ?? null, isMain: Number(m.is_main ?? 0) === 1 });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
/** B6:建号——拒外来号前缀、口令 ≥6、默认 must_change=1。返回新用户 id。 */
|
|
78
110
|
export async function createUser(input: { username: string, password: string, displayName?: string, email?: string }, deps: UserDeps): Promise<number> {
|
|
79
111
|
const username = String(input.username ?? "").trim();
|
|
@@ -87,12 +119,64 @@ export async function createUser(input: { username: string, password: string, di
|
|
|
87
119
|
if (exists.length)
|
|
88
120
|
fail(400, "username exists");
|
|
89
121
|
const hash = await deps.bcryptHash(String(input.password));
|
|
90
|
-
|
|
122
|
+
// activated=1:管理员建号是「明确要这个人」的动作,立刻进通讯录目录。
|
|
123
|
+
// 与同步建档相反——同步会把全企业名册灌进来,那批号必须等真人登录才算 activated(见 contacts.ts 门禁)。
|
|
124
|
+
const rows = await db.table("users").insert({ username, password_hash: hash, display_name: input.displayName ?? null, email: input.email ?? null, must_change: 1, activated: 1 }).returning(["id"]).run() as Array<{ id: number }>;
|
|
91
125
|
const uid = Number(rows[0].id);
|
|
92
126
|
await recordOp({ action: "user.create", module: "framework", target: username });
|
|
93
127
|
return uid;
|
|
94
128
|
}
|
|
95
129
|
|
|
130
|
+
/**
|
|
131
|
+
* 外部源(企微 / LDAP)同步建档:按用户名 find-or-create,返回 uid。
|
|
132
|
+
*
|
|
133
|
+
* 与 `createUser` 的三处刻意差异:
|
|
134
|
+
* 1. **activated=0**:同步灌进来的是「目录档」,要等真人登录过才算活人(§9.1,否则
|
|
135
|
+
* contacts 会被一次同步放大成整份企业名册);
|
|
136
|
+
* 2. 不写审计:按部门批量建号,逐条审计会把 sys_oplog 打爆(同步入口自己记一条总量);
|
|
137
|
+
* 3. 归属权(D13):已存在行的 `source` 与本次来源不同(管理员已「转为本地维护」)→
|
|
138
|
+
* 只回 id,**一个字段都不改**,改名/改职务都归本地管。
|
|
139
|
+
*
|
|
140
|
+
* 口令哈希仍用 `!<source>` 占位(与 `!wecom` / `!ldap` 同义:任何口令都验不过)。
|
|
141
|
+
*/
|
|
142
|
+
export async function upsertSourceUser(input: { username: string, displayName?: string | null, source: string, corpId?: string | null }): Promise<number> {
|
|
143
|
+
const username = String(input.username ?? "").trim();
|
|
144
|
+
if (!username)
|
|
145
|
+
fail(400, "username required");
|
|
146
|
+
const rows = await db.table("users").select(["id", "source"]).where({ field: "username", op: "eq", value: username }).all() as Array<{ id: number, source: string | null }>;
|
|
147
|
+
if (rows.length) {
|
|
148
|
+
const row = rows[0]!;
|
|
149
|
+
const name = input.displayName?.trim();
|
|
150
|
+
// 同步只改自己拥有的行:名字变了(改名/补上真名)就更新,其余字段(职务/状态)由调用方另写
|
|
151
|
+
if (name && String(row.source ?? "") === input.source)
|
|
152
|
+
await db.table("users").update({ display_name: name }).where({ field: "id", op: "eq", value: Number(row.id) }).run();
|
|
153
|
+
return Number(row.id);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const inserted = await db.table("users").insert({
|
|
157
|
+
username,
|
|
158
|
+
display_name: input.displayName?.trim() || username,
|
|
159
|
+
password_hash: `!${input.source}`,
|
|
160
|
+
avatar_base64: "",
|
|
161
|
+
source: input.source,
|
|
162
|
+
corp_id: input.corpId ?? null,
|
|
163
|
+
must_change: 0,
|
|
164
|
+
is_forbidden: 0,
|
|
165
|
+
activated: 0,
|
|
166
|
+
}).returning(["id"]).run() as Array<{ id: number }>;
|
|
167
|
+
return Number(inserted[0]!.id);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
// 并发(两片撞同一人 / 两个部门同时处理)唯一约束兜底:回查复用,同 saveProfile 先例
|
|
171
|
+
if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
|
|
172
|
+
throw e;
|
|
173
|
+
const again = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all() as Array<{ id: number }>;
|
|
174
|
+
if (!again.length)
|
|
175
|
+
throw e;
|
|
176
|
+
return Number(again[0]!.id);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
96
180
|
/** AC1-7:整集替换角色(sys_user_role 权威源),写后失效持有者判权缓存。 */
|
|
97
181
|
export async function updateUserRoles(actorId: number, targetUid: number, roles: string[]): Promise<void> {
|
|
98
182
|
assertNotSelf(actorId, targetUid);
|
|
@@ -161,3 +245,57 @@ export async function setForbidden(actorId: number, targetUid: number, forbidden
|
|
|
161
245
|
invalidateUser(Number(targetUid));
|
|
162
246
|
await recordOp({ action: "user.forbidden.update", module: "framework", target: await usernameOf(Number(targetUid)), detail: { forbidden } });
|
|
163
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 改资料(基线 CRUD 的用户侧)。
|
|
251
|
+
*
|
|
252
|
+
* 只放行与来源无关的展示字段(displayName / email / position)——用户名即外部身份的主键
|
|
253
|
+
* (`wecom:<userid>` 这类),改名会让下一次登录认错人(B6 命名空间抢占,见
|
|
254
|
+
* RESERVED_USERNAME_PREFIX)。`is_forbidden` 走 setForbidden、口令走 changePasswordByAdmin,
|
|
255
|
+
* 都不在这里——它们是带副作用的动作(级联删 refresh),混进通用 PATCH 会让「改个邮箱」也强退。
|
|
256
|
+
*/
|
|
257
|
+
export async function updateUserProfile(actorId: number, targetUid: number, patch: { displayName?: string | null, email?: string | null, position?: string | null }): Promise<void> {
|
|
258
|
+
const rows = await db.table("users").select(["id"]).where({ field: "id", op: "eq", value: targetUid }).all();
|
|
259
|
+
if (!rows.length)
|
|
260
|
+
fail(404, "user not found");
|
|
261
|
+
const cols: Record<string, unknown> = {};
|
|
262
|
+
if (patch.displayName !== undefined)
|
|
263
|
+
cols.display_name = patch.displayName == null ? null : String(patch.displayName).trim() || null;
|
|
264
|
+
if (patch.email !== undefined)
|
|
265
|
+
cols.email = patch.email == null ? null : String(patch.email).trim() || null;
|
|
266
|
+
if (patch.position !== undefined)
|
|
267
|
+
cols.position = patch.position == null ? null : String(patch.position).trim() || null;
|
|
268
|
+
if (!Object.keys(cols).length)
|
|
269
|
+
return;
|
|
270
|
+
await db.table("users").update(cols).where({ field: "id", op: "eq", value: targetUid }).run();
|
|
271
|
+
await recordOp({ action: "user.profile.update", module: "framework", target: await usernameOf(Number(targetUid)), detail: { fields: Object.keys(cols), actor: Number(actorId) } });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 删用户(基线 CRUD)。
|
|
276
|
+
*
|
|
277
|
+
* 级联清单是手工维护的——这些表都是逻辑外键(无 FK 约束),漏清任何一张都会留下
|
|
278
|
+
* 指向已删用户的悬空行:`sys_dept_member`(部门成员)/ `sys_user_role`(角色)
|
|
279
|
+
* / `sys_refresh_token`(会话)。删完立即失效判权缓存,否则被删者手上的 token
|
|
280
|
+
* 还能用满一个 TTL。
|
|
281
|
+
*/
|
|
282
|
+
export async function deleteUser(actorId: number, targetUid: number): Promise<void> {
|
|
283
|
+
assertNotSelf(actorId, targetUid);
|
|
284
|
+
const rows = await db.table("users").select(["id", "username"]).where({ field: "id", op: "eq", value: targetUid }).all() as Array<{ id: number, username: string }>;
|
|
285
|
+
if (!rows.length)
|
|
286
|
+
fail(404, "user not found");
|
|
287
|
+
const roles = await rolesOf(Number(targetUid));
|
|
288
|
+
if (roles.includes("superadmin")) {
|
|
289
|
+
const supers = await db.table("sys_user_role").select([{ fn: "count", as: "c" }]).where({ field: "role_name", op: "eq", value: "superadmin" }).all() as Array<{ c: number }>;
|
|
290
|
+
if (Number(supers[0]?.c ?? 0) <= 1)
|
|
291
|
+
fail(400, "cannot delete the last superadmin");
|
|
292
|
+
}
|
|
293
|
+
await db.tx(async (t) => {
|
|
294
|
+
await t.table("sys_dept_member").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
295
|
+
await t.table("sys_user_role").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
296
|
+
await t.table("sys_refresh_token").where({ field: "user_id", op: "eq", value: targetUid }).delete().run();
|
|
297
|
+
await t.table("users").where({ field: "id", op: "eq", value: targetUid }).delete().run();
|
|
298
|
+
});
|
|
299
|
+
invalidateUser(Number(targetUid));
|
|
300
|
+
await recordOp({ action: "user.delete", module: "framework", target: String(rows[0]!.username) });
|
|
301
|
+
}
|
|
@@ -15,7 +15,8 @@ export interface PageResult<T> {
|
|
|
15
15
|
const MAX_SIZE = 200;
|
|
16
16
|
const DEFAULT_SIZE = 20;
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** 分页参数归一(非法回落默认,size 静默钳 200);导给「内存分页」的读面复用,避免钳制口径分叉 */
|
|
19
|
+
export function pageArgs(input: { page?: unknown, size?: unknown }): { page: number, size: number, offset: number } {
|
|
19
20
|
const rawPage = Number.parseInt(String(input.page ?? ""), 10);
|
|
20
21
|
const rawSize = Number.parseInt(String(input.size ?? ""), 10);
|
|
21
22
|
const page = Number.isFinite(rawPage) && rawPage >= 1 ? rawPage : 1;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- L1 测试账号(仅 `oj test` 经 fixtures 灌入,不进 release 产物)。
|
|
2
2
|
--
|
|
3
3
|
-- 为什么不能用 seed 的开发账号(admin/superadmin 等)跑 L1 登录:
|
|
4
|
-
-- ① seed 账号初始口令
|
|
4
|
+
-- ① seed 账号初始口令 aA123456 只是开发便利——用户在 dev 库改过口令后,口令即漂移,
|
|
5
5
|
-- L1 若绑 seed 账号会随机 401(本地实测踩过:dev 库口令被改成 12345678)。
|
|
6
6
|
-- ② 不用 must_change 解释:seed 账号现均为 0(2026-10-22 口径);真正的原因是 ①。
|
|
7
7
|
-- 故 additive 建一个专用账号:INSERT OR IGNORE 幂等,反复重放终态一致,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- 0016__org(mysql 方言):组织架构领域。表定性与列语义同 0016__org.sql 头注释。
|
|
2
|
+
-- 差异(沿用 0001__create_users.mysql.sql / 0015__tenant.mysql.sql 的既有惯例):
|
|
3
|
+
-- · TEXT 不能带字面默认值、不能直接做唯一键 → 键列按业务长度收窄为 VARCHAR;
|
|
4
|
+
-- · AUTOINCREMENT → BIGINT AUTO_INCREMENT;秒级 epoch 的 INTEGER → BIGINT;
|
|
5
|
+
-- · 索引写在 CREATE TABLE 内(MySQL 无 CREATE INDEX IF NOT EXISTS);
|
|
6
|
+
-- · 复合唯一键 (tenant_id, origin, origin_id) = (255+32+128) × 4B = 1660B < InnoDB 3072B 上限。
|
|
7
|
+
CREATE TABLE IF NOT EXISTS sys_department (
|
|
8
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
9
|
+
tenant_id VARCHAR(255) NOT NULL,
|
|
10
|
+
origin VARCHAR(32) NOT NULL DEFAULT 'local',
|
|
11
|
+
origin_id VARCHAR(128) NOT NULL,
|
|
12
|
+
parent_id BIGINT NULL,
|
|
13
|
+
name VARCHAR(255),
|
|
14
|
+
name_en VARCHAR(255),
|
|
15
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
sync_visible INTEGER NOT NULL DEFAULT 1,
|
|
17
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
seen_job_id BIGINT,
|
|
19
|
+
update_time BIGINT NOT NULL,
|
|
20
|
+
UNIQUE KEY uk_sys_department_origin (tenant_id, origin, origin_id),
|
|
21
|
+
KEY idx_sys_department_parent (tenant_id, parent_id, deleted, order_no)
|
|
22
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS sys_dept_member (
|
|
25
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
26
|
+
tenant_id VARCHAR(255) NOT NULL,
|
|
27
|
+
user_id BIGINT NOT NULL,
|
|
28
|
+
dept_id BIGINT NOT NULL,
|
|
29
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
is_leader INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
is_main INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
update_time BIGINT NOT NULL,
|
|
33
|
+
UNIQUE KEY uk_sys_dept_member (tenant_id, user_id, dept_id),
|
|
34
|
+
KEY idx_sys_dept_member_dept (tenant_id, dept_id, order_no),
|
|
35
|
+
KEY idx_sys_dept_member_user (tenant_id, user_id, is_main)
|
|
36
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
37
|
+
|
|
38
|
+
ALTER TABLE users ADD COLUMN activated INTEGER NOT NULL DEFAULT 0;
|
|
39
|
+
ALTER TABLE users ADD COLUMN position VARCHAR(255) NULL;
|
|
40
|
+
ALTER TABLE users ADD COLUMN external_status INTEGER NULL;
|
|
41
|
+
UPDATE users SET activated = 1;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- 0016__org(postgres 方言):组织架构领域。表定性与列语义同 0016__org.sql 头注释。
|
|
2
|
+
-- 差异(沿用 0001__create_users.pg.sql 的既有惯例):
|
|
3
|
+
-- · AUTOINCREMENT → BIGSERIAL(SERIAL 系允许 seed 显式 id,GENERATED ALWAYS 系不允许);
|
|
4
|
+
-- · TEXT 在 pg 合法,键列与文本列一律保留 TEXT,无需收窄;
|
|
5
|
+
-- · 秒级 epoch 的 INTEGER → BIGINT。
|
|
6
|
+
CREATE TABLE IF NOT EXISTS sys_department (
|
|
7
|
+
id BIGSERIAL PRIMARY KEY,
|
|
8
|
+
tenant_id TEXT NOT NULL,
|
|
9
|
+
origin TEXT NOT NULL DEFAULT 'local',
|
|
10
|
+
origin_id TEXT NOT NULL,
|
|
11
|
+
parent_id BIGINT,
|
|
12
|
+
name TEXT,
|
|
13
|
+
name_en TEXT,
|
|
14
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
sync_visible INTEGER NOT NULL DEFAULT 1,
|
|
16
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
seen_job_id BIGINT,
|
|
18
|
+
update_time BIGINT NOT NULL,
|
|
19
|
+
UNIQUE (tenant_id, origin, origin_id)
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_sys_department_parent ON sys_department (tenant_id, parent_id, deleted, order_no);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sys_dept_member (
|
|
24
|
+
id BIGSERIAL PRIMARY KEY,
|
|
25
|
+
tenant_id TEXT NOT NULL,
|
|
26
|
+
user_id BIGINT NOT NULL,
|
|
27
|
+
dept_id BIGINT NOT NULL,
|
|
28
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
is_leader INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
is_main INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
update_time BIGINT NOT NULL,
|
|
32
|
+
UNIQUE (tenant_id, user_id, dept_id)
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_sys_dept_member_dept ON sys_dept_member (tenant_id, dept_id, order_no);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_sys_dept_member_user ON sys_dept_member (tenant_id, user_id, is_main);
|
|
36
|
+
|
|
37
|
+
ALTER TABLE users ADD COLUMN activated INTEGER NOT NULL DEFAULT 0;
|
|
38
|
+
ALTER TABLE users ADD COLUMN position TEXT;
|
|
39
|
+
ALTER TABLE users ADD COLUMN external_status INTEGER;
|
|
40
|
+
UPDATE users SET activated = 1;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- 0016__org(sqlite 方言):组织架构领域(CRUD 基线 + 外部源适配器的落点)
|
|
2
|
+
--
|
|
3
|
+
-- 两张表都是**租户表**(tenant: true):组织架构天然按租户隔离(企微部署下 tenant_id = corp_id),
|
|
4
|
+
-- 只在有租户上下文时被读写,与 users / sys_oplog 同组(0015 A 组口径)。
|
|
5
|
+
-- 故**不**进 config.yaml 的 tenant.shared_allow——那里只放全局映射/凭证。
|
|
6
|
+
--
|
|
7
|
+
-- 定位(方案 §14:CRUD 是基线,企微 / LDAP 只是适配器):
|
|
8
|
+
-- · 表里不出现任何 wecom_* / ldap_* 字段,来源只由 origin 表达;
|
|
9
|
+
-- · origin 是**归属权**标识(D13):local = 本站管理员全权可 CRUD;
|
|
10
|
+
-- wecom / ldap / sso = 该适配器管辖,同步与回收只作用于自己 origin 的行。
|
|
11
|
+
--
|
|
12
|
+
-- 列的来源语义:
|
|
13
|
+
-- origin_id 外部源主键(企微部门 id / LDAP entryUUID);origin='local' 时由应用层生成稳定串
|
|
14
|
+
-- parent_id 本地父部门 id(不是外部 id)——本地部门与同步部门可混挂,且不随外部调整而变
|
|
15
|
+
-- name 未获权限时为 NULL(企微代开发未授权「部门名」即如此),展示层用 origin_id 兜底
|
|
16
|
+
-- sync_visible 同步可见性,**仅由同步轮次维护**(失去可见范围 → 0),本地部门恒 1
|
|
17
|
+
-- deleted 软删除。**只由外部源的删除事件置 1**(D9):本地删除走真删,见 §14.4-②
|
|
18
|
+
-- seen_job_id 最近一次完整全量命中的 job,用于墓碑回收(回收前必先校验本轮完整且无 skip)
|
|
19
|
+
CREATE TABLE IF NOT EXISTS sys_department (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
tenant_id TEXT NOT NULL,
|
|
22
|
+
origin TEXT NOT NULL DEFAULT 'local',
|
|
23
|
+
origin_id TEXT NOT NULL,
|
|
24
|
+
parent_id INTEGER,
|
|
25
|
+
name TEXT,
|
|
26
|
+
name_en TEXT,
|
|
27
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
sync_visible INTEGER NOT NULL DEFAULT 1,
|
|
29
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
seen_job_id INTEGER,
|
|
31
|
+
update_time INTEGER NOT NULL,
|
|
32
|
+
UNIQUE (tenant_id, origin, origin_id)
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_sys_department_parent ON sys_department (tenant_id, parent_id, deleted, order_no);
|
|
35
|
+
|
|
36
|
+
-- 成员 ↔ 部门(多对多):企微一个人可在多个部门,order / is_leader 与 department[] 逐位对应。
|
|
37
|
+
-- 全本地 id 关联(user_id + dept_id),**与来源完全无关**,接任何适配器都不用改这张表。
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sys_dept_member (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
tenant_id TEXT NOT NULL,
|
|
41
|
+
user_id INTEGER NOT NULL,
|
|
42
|
+
dept_id INTEGER NOT NULL,
|
|
43
|
+
order_no INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
is_leader INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
is_main INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
update_time INTEGER NOT NULL,
|
|
47
|
+
UNIQUE (tenant_id, user_id, dept_id)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_sys_dept_member_dept ON sys_dept_member (tenant_id, dept_id, order_no);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_sys_dept_member_user ON sys_dept_member (tenant_id, user_id, is_main);
|
|
51
|
+
|
|
52
|
+
-- users 扩列(三列都是与源无关的通用字段)
|
|
53
|
+
-- activated 是否登录过本站。同步会为「从未登录」的全员建档,若不加这道闸门,
|
|
54
|
+
-- contacts 目录(60 次/分、keyword≥2)可被用来遍历拖走整份企业名册(§9.1)
|
|
55
|
+
-- position 职务(本地 / LDAP / 企微都能填)
|
|
56
|
+
-- external_status 外部源侧的状态快照(企微 1/2/4/5)。**绝不自动改写 is_forbidden**——
|
|
57
|
+
-- 那会和本站管理员的手动启停互相覆盖(§4.3)
|
|
58
|
+
ALTER TABLE users ADD COLUMN activated INTEGER NOT NULL DEFAULT 0;
|
|
59
|
+
ALTER TABLE users ADD COLUMN position TEXT;
|
|
60
|
+
ALTER TABLE users ADD COLUMN external_status INTEGER;
|
|
61
|
+
-- 存量账号一律视为已激活:保持 contacts 的既有行为不变(它此前本就返回全部 users)
|
|
62
|
+
UPDATE users SET activated = 1;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// GET /api/_platform/org/departments —— 部门平铺分页(共享读面)。
|
|
3
|
+
// 与 /org/tree 的差别在**分页与过滤**:大企业别一次拉全树,按 parent / keyword 分片取。
|
|
4
|
+
// parent=0 表示只要根层;不给 parent 即跨层全查(仍受 scope 收窄)。
|
|
5
|
+
import { pageDepartments, resolveReadFace } from "../../_shared/service/domain/org-read";
|
|
6
|
+
|
|
7
|
+
const DEPT_ITEM = {
|
|
8
|
+
type: "object",
|
|
9
|
+
required: ["id", "parentId", "name", "orderNo", "memberCount"],
|
|
10
|
+
properties: {
|
|
11
|
+
id: { type: "number" },
|
|
12
|
+
parentId: { type: ["number", "null"] },
|
|
13
|
+
name: { type: ["string", "null"] },
|
|
14
|
+
nameEn: { type: ["string", "null"] },
|
|
15
|
+
orderNo: { type: "number" },
|
|
16
|
+
memberCount: { type: "number" },
|
|
17
|
+
},
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
async function get() {
|
|
21
|
+
await respond(async () => {
|
|
22
|
+
const q = http.query as Record<string, unknown>;
|
|
23
|
+
const reader = await resolveReadFace(q.scope);
|
|
24
|
+
return pageDepartments(reader, { parent: q.parent, keyword: q.keyword, page: q.page, size: q.size });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
get.schema = {
|
|
28
|
+
query: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
scope: { type: "string", enum: ["self", "all"] },
|
|
32
|
+
parent: { type: "number" },
|
|
33
|
+
keyword: { type: "string" },
|
|
34
|
+
page: { type: "number", minimum: 1 },
|
|
35
|
+
size: { type: "number", minimum: 1, maximum: 200 },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
get.response = {
|
|
40
|
+
type: "object",
|
|
41
|
+
required: ["list", "total", "page", "size", "truncated"],
|
|
42
|
+
properties: {
|
|
43
|
+
list: { type: "array", items: DEPT_ITEM },
|
|
44
|
+
total: { type: "number" },
|
|
45
|
+
page: { type: "number" },
|
|
46
|
+
size: { type: "number" },
|
|
47
|
+
truncated: { type: "boolean" },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default { get };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// GET /api/_platform/org/is-member?uid=&dept= —— 某人是否在某部门(**含子部门**)下。
|
|
3
|
+
// 第二个「算」型端点:按部门做访问控制/路由时,调用方不该自己去拉树递归。
|
|
4
|
+
// scope=self 下只能问自己(问别人 → 403)。
|
|
5
|
+
import { isMemberOf, resolveReadFace } from "../../_shared/service/domain/org-read";
|
|
6
|
+
|
|
7
|
+
async function get() {
|
|
8
|
+
await respond(async () => {
|
|
9
|
+
const q = http.query as Record<string, unknown>;
|
|
10
|
+
const reader = await resolveReadFace(q.scope);
|
|
11
|
+
const uid = q.uid == null || String(q.uid).trim() === "" ? reader.uid : Number(q.uid);
|
|
12
|
+
return { isMember: await isMemberOf(reader, uid, Number(q.dept)) };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
get.schema = {
|
|
16
|
+
query: {
|
|
17
|
+
type: "object",
|
|
18
|
+
required: ["dept"],
|
|
19
|
+
properties: {
|
|
20
|
+
scope: { type: "string", enum: ["self", "all"] },
|
|
21
|
+
// 不给 uid 即问自己
|
|
22
|
+
uid: { type: "number" },
|
|
23
|
+
dept: { type: "number" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
get.response = {
|
|
28
|
+
type: "object",
|
|
29
|
+
required: ["isMember"],
|
|
30
|
+
properties: { isMember: { type: "boolean" } },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default { get };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// GET /api/_platform/org/members —— 部门成员 + 部门路径(共享读面)。
|
|
3
|
+
// 不给 dept 即查可见范围内的全部成员(配 keyword 用)。
|
|
4
|
+
// ⚡ includeSub 在 scope=self 下只对「我所在部门」生效:对上级部门一展开就是整棵子树,
|
|
5
|
+
// 那等于绕过自身路径的限制,所以宁可 400 也不静默截断(见 org-read.ts 头注)。
|
|
6
|
+
import { pageMembers, resolveReadFace } from "../../_shared/service/domain/org-read";
|
|
7
|
+
|
|
8
|
+
const MEMBER_ITEM = {
|
|
9
|
+
type: "object",
|
|
10
|
+
required: ["userId", "username", "deptId", "deptPath", "isMain"],
|
|
11
|
+
properties: {
|
|
12
|
+
userId: { type: "number" },
|
|
13
|
+
username: { type: "string" },
|
|
14
|
+
displayName: { type: ["string", "null"] },
|
|
15
|
+
deptId: { type: "number" },
|
|
16
|
+
// 根在前的部门名路径(部门名缺失时退 `#id`)
|
|
17
|
+
deptPath: { type: "array", items: { type: "string" } },
|
|
18
|
+
isMain: { type: "boolean" },
|
|
19
|
+
},
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
async function get() {
|
|
23
|
+
await respond(async () => {
|
|
24
|
+
const q = http.query as Record<string, unknown>;
|
|
25
|
+
const reader = await resolveReadFace(q.scope);
|
|
26
|
+
return pageMembers(reader, { dept: q.dept, includeSub: q.includeSub, keyword: q.keyword, page: q.page, size: q.size });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
get.schema = {
|
|
30
|
+
query: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
scope: { type: "string", enum: ["self", "all"] },
|
|
34
|
+
dept: { type: "number" },
|
|
35
|
+
// 查询串是字符串:`true` / `1` 视为展开子部门
|
|
36
|
+
includeSub: { type: "string" },
|
|
37
|
+
keyword: { type: "string" },
|
|
38
|
+
page: { type: "number", minimum: 1 },
|
|
39
|
+
size: { type: "number", minimum: 1, maximum: 200 },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
get.response = {
|
|
44
|
+
type: "object",
|
|
45
|
+
required: ["list", "total", "page", "size", "truncated"],
|
|
46
|
+
properties: {
|
|
47
|
+
list: { type: "array", items: MEMBER_ITEM },
|
|
48
|
+
total: { type: "number" },
|
|
49
|
+
page: { type: "number" },
|
|
50
|
+
size: { type: "number" },
|
|
51
|
+
truncated: { type: "boolean" },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default { get };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// GET /api/_platform/org/picker?keyword= —— 人 + 部门混合(选人组件一次拿全)。
|
|
3
|
+
// ⚡ keyword 必填且 ≥2:选人组件是**枚举面**(拿单字能把整份名册拖走),与通讯录同一条红线。
|
|
4
|
+
import { readPicker, resolveReadFace } from "../../_shared/service/domain/org-read";
|
|
5
|
+
|
|
6
|
+
const DEPT_ITEM = {
|
|
7
|
+
type: "object",
|
|
8
|
+
required: ["id", "parentId", "name", "orderNo", "memberCount"],
|
|
9
|
+
properties: {
|
|
10
|
+
id: { type: "number" },
|
|
11
|
+
parentId: { type: ["number", "null"] },
|
|
12
|
+
name: { type: ["string", "null"] },
|
|
13
|
+
nameEn: { type: ["string", "null"] },
|
|
14
|
+
orderNo: { type: "number" },
|
|
15
|
+
memberCount: { type: "number" },
|
|
16
|
+
},
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
const USER_ITEM = {
|
|
20
|
+
type: "object",
|
|
21
|
+
required: ["userId", "username", "deptId", "deptPath", "isMain"],
|
|
22
|
+
properties: {
|
|
23
|
+
userId: { type: "number" },
|
|
24
|
+
username: { type: "string" },
|
|
25
|
+
displayName: { type: ["string", "null"] },
|
|
26
|
+
deptId: { type: "number" },
|
|
27
|
+
deptPath: { type: "array", items: { type: "string" } },
|
|
28
|
+
isMain: { type: "boolean" },
|
|
29
|
+
},
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
async function get() {
|
|
33
|
+
await respond(async () => {
|
|
34
|
+
const q = http.query as Record<string, unknown>;
|
|
35
|
+
const reader = await resolveReadFace(q.scope);
|
|
36
|
+
return readPicker(reader, { keyword: q.keyword, limit: q.limit });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
get.schema = {
|
|
40
|
+
query: {
|
|
41
|
+
type: "object",
|
|
42
|
+
required: ["keyword"],
|
|
43
|
+
properties: {
|
|
44
|
+
scope: { type: "string", enum: ["self", "all"] },
|
|
45
|
+
keyword: { type: "string", minLength: 2 },
|
|
46
|
+
limit: { type: "number", minimum: 1, maximum: 50 },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
get.response = {
|
|
51
|
+
type: "object",
|
|
52
|
+
required: ["departments", "users"],
|
|
53
|
+
properties: {
|
|
54
|
+
departments: { type: "array", items: DEPT_ITEM },
|
|
55
|
+
users: { type: "array", items: USER_ITEM },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default { get };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// POST /api/_platform/org/resolve —— 把「部门(含子部门)」展开成 user_id 列表。
|
|
3
|
+
//
|
|
4
|
+
// ⚡ 这是共享读面**真正的价值**:需求方要的是「算」不是「看」。缺了它,每个消费模块都会自己
|
|
5
|
+
// 把树拉下来递归,共享名存实亡(方案 §5.2)。典型用法:通知/审批按「某部门及其下级」定收件人。
|
|
6
|
+
//
|
|
7
|
+
// 结果去重升序,可直接当收件人集合用;显式传的 userIds 原样并入(再去重)。
|
|
8
|
+
import { resolveReadFace, resolveUserIds } from "../../_shared/service/domain/org-read";
|
|
9
|
+
|
|
10
|
+
async function post() {
|
|
11
|
+
await respond(async () => {
|
|
12
|
+
const q = http.query as Record<string, unknown>;
|
|
13
|
+
const reader = await resolveReadFace(q.scope);
|
|
14
|
+
const b = (http.body || {}) as { deptIds?: unknown, userIds?: unknown, includeSub?: boolean };
|
|
15
|
+
return { userIds: await resolveUserIds(reader, { deptIds: b.deptIds, userIds: b.userIds, includeSub: b.includeSub }) };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
post.schema = {
|
|
19
|
+
query: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { scope: { type: "string", enum: ["self", "all"] } },
|
|
22
|
+
},
|
|
23
|
+
body: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
deptIds: { type: "array", items: { type: "number" } },
|
|
27
|
+
userIds: { type: "array", items: { type: "number" } },
|
|
28
|
+
includeSub: { type: "boolean" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
post.response = {
|
|
33
|
+
type: "object",
|
|
34
|
+
required: ["userIds"],
|
|
35
|
+
properties: { userIds: { type: "array", items: { type: "number" } } },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default { post };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { respond } from "../../_shared/respond";
|
|
2
|
+
// GET /api/_platform/org/tree —— 部门树(共享读面,方案 A3 / §5.2)。
|
|
3
|
+
//
|
|
4
|
+
// **返回平表而不是嵌套树**:oj 的契约 IR 不支持递归 schema(无 $ref),嵌套深度写不进契约;
|
|
5
|
+
// 树由前端按 parentId 组装(与管理端 /api/system/departments 同口径)。
|
|
6
|
+
//
|
|
7
|
+
// 本端点只出「活着且同步可见」的部门(软删与失去可见范围的行不出)——那是管理端的事。
|
|
8
|
+
import { readTree, resolveReadFace } from "../../_shared/service/domain/org-read";
|
|
9
|
+
|
|
10
|
+
const DEPT_ITEM = {
|
|
11
|
+
type: "object",
|
|
12
|
+
required: ["id", "parentId", "name", "orderNo", "memberCount"],
|
|
13
|
+
properties: {
|
|
14
|
+
id: { type: "number" },
|
|
15
|
+
parentId: { type: ["number", "null"] },
|
|
16
|
+
name: { type: ["string", "null"] },
|
|
17
|
+
nameEn: { type: ["string", "null"] },
|
|
18
|
+
orderNo: { type: "number" },
|
|
19
|
+
memberCount: { type: "number" },
|
|
20
|
+
},
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
async function get() {
|
|
24
|
+
await respond(async () => {
|
|
25
|
+
const reader = await resolveReadFace((http.query as Record<string, unknown>).scope);
|
|
26
|
+
return readTree(reader);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
get.schema = {
|
|
30
|
+
query: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { scope: { type: "string", enum: ["self", "all"] } },
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
get.response = { type: "array", items: DEPT_ITEM };
|
|
36
|
+
|
|
37
|
+
export default { get };
|