@oj-module/cli 0.2.43 → 0.2.45
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/README.md +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +392 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +226 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -23,7 +23,7 @@ export interface SmokeContext extends InstallContext {
|
|
|
23
23
|
/** API 前缀(C2-13 api.prefix,缺省 /api/<name>;探针 URL 前缀,注入不硬编码) */
|
|
24
24
|
apiPrefix: string
|
|
25
25
|
/** 注入式探针 fetch 面(生产 = isolate 全局 fetch 同源调用;测试 stub 不真发请求) */
|
|
26
|
-
fetch: (url: string) => Promise<{ ok: boolean, status: number }>
|
|
26
|
+
fetch: (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean, status: number }>
|
|
27
27
|
/** 注入式 sha384 摘要面(base64 裸摘要,不带 sha384- 前缀;同 verify.ts DIP) */
|
|
28
28
|
sha384B64: (data: Uint8Array) => string
|
|
29
29
|
/** 站点 modules.json 路径(integrity 比对真源 + 落位文件定位基准:<dist>/modules.json) */
|
|
@@ -55,6 +55,15 @@ function isObj(v: unknown): v is Record<string, unknown> {
|
|
|
55
55
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 探活请求必须自报租户(企微代开发多租户 v3 Phase 0):宿主 `tenant.enable: true` 时 oj 对每个
|
|
60
|
+
* 请求强制要 `X-TENANT-ID`——**匿名健康端点也不豁免**,缺即 400(实测 curl 无头 400 / 带 default 200)。
|
|
61
|
+
* 浏览器由 runtime 请求层注入;安装器是进程内自探,没人替它注入,只能自己带上,否则任何
|
|
62
|
+
* 装到「带 health 的方案」上的站点都会卡在 smoke 段(`install/upgrade/reinstall` 全线 partial)。
|
|
63
|
+
*/
|
|
64
|
+
const PROBE_TENANT_HEADER = "X-TENANT-ID";
|
|
65
|
+
const PROBE_TENANT = "default";
|
|
66
|
+
|
|
58
67
|
/** 探针 URL 形态:<apiPrefix>/<path 去前导斜杠>(apiPrefix 尾斜杠归一;C2-26 段内收敛同构) */
|
|
59
68
|
export function probeUrlOf(apiPrefix: string, path: string): string {
|
|
60
69
|
return `${apiPrefix.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
@@ -128,16 +137,15 @@ export async function smoke(ctx: SmokeContext): Promise<StageResult> {
|
|
|
128
137
|
const target = health ?? firstNonAnonymousRoute(m);
|
|
129
138
|
if (target !== undefined) {
|
|
130
139
|
const url = probeUrlOf(ctx.apiPrefix, target);
|
|
131
|
-
const res = await ctx.fetch(url);
|
|
140
|
+
const res = await ctx.fetch(url, { headers: { [PROBE_TENANT_HEADER]: PROBE_TENANT } });
|
|
132
141
|
if (health !== undefined && !res.ok) {
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const deferred = res.status === 401 && pending;
|
|
142
|
+
// health 探针本就是「无凭证预期 401」(文件头注释:非匿名无凭证预期 401;D14/Q-A:health 不强制)。
|
|
143
|
+
// 租户化站点上安装/重装/升级后宿主需重启才能加载新配置(含匿名接线、路由),探针在重启前收 401
|
|
144
|
+
// 是 restart_pending 的常态,不是包或站点的问题——一律判 deferred,不计入失败(P2 深检可兜底真异常)。
|
|
145
|
+
// 非 401(如 500)仍视为真实失败,正常报错。
|
|
146
|
+
const deferred = res.status === 401;
|
|
139
147
|
if (deferred) {
|
|
140
|
-
notes.push(`探活 ${url} 返回 401
|
|
148
|
+
notes.push(`探活 ${url} 返回 401:health 端点为无凭证探针,宿主重启加载新配置(路由/匿名接线)后即 2xx——属 restart_pending,重启宿主后生效,不计入失败`);
|
|
141
149
|
}
|
|
142
150
|
else {
|
|
143
151
|
failures.push({ item: "探活", retryable: true, reason: `health 端点 ${url} 返回 HTTP ${res.status}(C2-33 冒烟探针要求 2xx)` });
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
// 但 oj 运行时对未捕获异常一律落 500 信封(不读 err.status)——
|
|
4
4
|
// 所有 handler 的业务体必须经 respond 收口成 json.fail(status, msg, extra),
|
|
5
5
|
// 否则 D14 统一 401 / B5 429 retryAfterMs / 403 must_change / 409 holders 全被抹成 500。
|
|
6
|
+
/** 异常 → fail 信封。redirect / 流式等「不能先 ok 再 fail」的出口也直接复用它。 */
|
|
7
|
+
export function failWith(e: unknown): void {
|
|
8
|
+
const err = e as Error & { status?: number } & Record<string, unknown>;
|
|
9
|
+
const data = Object.fromEntries(Object.entries(err).filter(([k]) => k !== "status" && k !== "message"));
|
|
10
|
+
if (Object.keys(data).length > 0)
|
|
11
|
+
json.fail(err.status ?? 500, err.message, data);
|
|
12
|
+
else
|
|
13
|
+
json.fail(err.status ?? 500, err.message);
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export async function respond<T>(fn: () => Promise<T>): Promise<void> {
|
|
7
17
|
try {
|
|
8
18
|
json.ok(await fn());
|
|
9
19
|
}
|
|
10
20
|
catch (e) {
|
|
11
|
-
|
|
12
|
-
const data = Object.fromEntries(Object.entries(err).filter(([k]) => k !== "status" && k !== "message"));
|
|
13
|
-
if (Object.keys(data).length > 0)
|
|
14
|
-
json.fail(err.status ?? 500, err.message, data);
|
|
15
|
-
else
|
|
16
|
-
json.fail(err.status ?? 500, err.message);
|
|
21
|
+
failWith(e);
|
|
17
22
|
}
|
|
18
23
|
}
|
|
@@ -68,9 +68,11 @@ export async function updateSetting(actorId: number, key: string, value: string)
|
|
|
68
68
|
await recordOp({ action: "setting.update", module: String(row.module), target: key });
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** C5/D2:登录方式发现的数据源——local 恒在,ldap/sso 由设置项开关。 */
|
|
71
|
+
/** C5/D2:登录方式发现的数据源——local 恒在,ldap/sso/wecom 由设置项开关。 */
|
|
72
72
|
export async function listLoginMethods(): Promise<{ methods: string[] }> {
|
|
73
|
-
const rows = await db.table("sys_setting").select(["key", "value"]).where({ field: "key", op: "in", value: ["ldap.enabled", "sso.enabled"] }).all() as Array<{ key: string, value: string | null }>;
|
|
73
|
+
const rows = await db.table("sys_setting").select(["key", "value"]).where({ field: "key", op: "in", value: ["ldap.enabled", "sso.enabled", "wecom.enabled"] }).all() as Array<{ key: string, value: string | null }>;
|
|
74
74
|
const enabled = (k: string) => rows.find(r => r.key === k)?.value === "1";
|
|
75
|
-
|
|
75
|
+
// wecom 的凭据在部署常量(config vars),这里只报开关:开关开了但凭据没配,
|
|
76
|
+
// 点进企微登录会拿到 400「企业微信未配置」——比登录页凭空少一个 tab 更容易排查。
|
|
77
|
+
return { methods: ["local", ...(enabled("ldap.enabled") ? ["ldap" as const] : []), ...(enabled("sso.enabled") ? ["sso" as const] : []), ...(enabled("wecom.enabled") ? ["wecom" as const] : [])] };
|
|
76
78
|
}
|
|
@@ -24,8 +24,14 @@ function fail(status: number, message: string): never {
|
|
|
24
24
|
throw Object.assign(new Error(message), { status });
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 外来号命名空间抢占红线(B6):本地建号拒收 ldap:/sso:/wecom: 前缀用户名。
|
|
29
|
+
*
|
|
30
|
+
* 导出给「改名」两侧共用:只在建号处拦是不够的——自助改名或管理端改名若能把任意一个
|
|
31
|
+
* 本地账号改成一个尚未建档的外部号(如 `wecom:boss`),该外部成员首次登录时
|
|
32
|
+
* `ensureWecomUser` 按用户名查到的是**别人那一行**,于是签发别人的 uid。
|
|
33
|
+
*/
|
|
34
|
+
export const RESERVED_USERNAME_PREFIX = /^(?:ldap|sso|wecom):/i;
|
|
29
35
|
|
|
30
36
|
async function rolesOf(uid: number): Promise<string[]> {
|
|
31
37
|
const rows = await db.table("sys_user_role").select(["role_name"]).where({ field: "user_id", op: "eq", value: uid }).all() as Array<{ role_name: string }>;
|
|
@@ -73,7 +79,7 @@ export async function createUser(input: { username: string, password: string, di
|
|
|
73
79
|
const username = String(input.username ?? "").trim();
|
|
74
80
|
if (!username)
|
|
75
81
|
fail(400, "username required");
|
|
76
|
-
if (
|
|
82
|
+
if (RESERVED_USERNAME_PREFIX.test(username))
|
|
77
83
|
fail(400, "username prefix reserved for external identity sources");
|
|
78
84
|
if (!input.password || String(input.password).length < 6)
|
|
79
85
|
fail(400, "password too short (min 6)");
|
|
@@ -9,6 +9,15 @@ import { decideAccess } from "./guard";
|
|
|
9
9
|
const TTL_MS = 10_000;
|
|
10
10
|
|
|
11
11
|
interface AccessSnapshot {
|
|
12
|
+
/**
|
|
13
|
+
* token 的 sub 在本租户里是否存在。
|
|
14
|
+
*
|
|
15
|
+
* 这是「伪造 X-TENANT-ID」的唯一服务端拦截点(R6 `require_signed_claim` 落地前的兜底):
|
|
16
|
+
* token 只带 sub、不带租户,而 `users` 已租户化(构造器自动注入 `tenant_id = 当前头值`),
|
|
17
|
+
* 于是「A 企业的人带 B 企业的头」表现为**查无此行**。若这里放行,只要求登录态的端点
|
|
18
|
+
* (通知已读、个人资料等)就会带着伪造的租户头去读写别家数据。
|
|
19
|
+
*/
|
|
20
|
+
exists: boolean
|
|
12
21
|
roles: string[]
|
|
13
22
|
bindings: Record<string, string[]>
|
|
14
23
|
forbidden: boolean
|
|
@@ -17,7 +26,21 @@ interface AccessSnapshot {
|
|
|
17
26
|
at: number
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
/**
|
|
30
|
+
* `uid → 快照` 的键必须带上租户(2026-10 多租户)。
|
|
31
|
+
*
|
|
32
|
+
* 反例:只按 uid 缓存时,「A 企业的张三」判权后在同一进程里走了「B 企业的张三」的请求——
|
|
33
|
+
* 后者的 `users` 行按 B 租户查不到(`tenant.enable` 下构造器自动注入谓词),本应 403,
|
|
34
|
+
* 但缓存里还留着 A 租户那张 `exists: true` 的快照,于是放行。进程不跨请求复用时看不到,
|
|
35
|
+
* 一旦复用(或在本工程的测试宿主里)就是静默的跨租户放行。
|
|
36
|
+
*/
|
|
37
|
+
const cache = new Map<string, AccessSnapshot>();
|
|
38
|
+
|
|
39
|
+
/** 当前请求的租户(`X-TENANT-ID` 落到 http.tenantId,与 oj 同源);无上下文时按空串 */
|
|
40
|
+
function requestTenant(): string {
|
|
41
|
+
const t = (http as unknown as { tenantId?: unknown }).tenantId;
|
|
42
|
+
return typeof t === "string" ? t.trim() : "";
|
|
43
|
+
}
|
|
21
44
|
|
|
22
45
|
async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
23
46
|
const userRows = await db.table("users").select(["is_forbidden", "must_change"]).where({ field: "id", op: "eq", value: userId }).all();
|
|
@@ -31,6 +54,7 @@ async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
|
31
54
|
for (const r of bindRows as { role_name: string, permission_code: string }[])
|
|
32
55
|
(bindings[r.role_name] ??= []).push(r.permission_code);
|
|
33
56
|
return {
|
|
57
|
+
exists: userRows.length > 0,
|
|
34
58
|
roles,
|
|
35
59
|
bindings,
|
|
36
60
|
forbidden: Number((userRows[0] || {}).is_forbidden) === 1,
|
|
@@ -40,17 +64,23 @@ async function loadSnapshot(userId: number): Promise<AccessSnapshot> {
|
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
async function snapshot(userId: number): Promise<AccessSnapshot> {
|
|
43
|
-
const
|
|
67
|
+
const key = `${requestTenant()}|${userId}`;
|
|
68
|
+
const hit = cache.get(key);
|
|
44
69
|
if (hit && Date.now() - hit.at < TTL_MS)
|
|
45
70
|
return hit;
|
|
46
71
|
const snap = await loadSnapshot(userId);
|
|
47
|
-
cache.set(
|
|
72
|
+
cache.set(key, snap);
|
|
48
73
|
return snap;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/** 写路径(角色分配/绑定调整/紧急吊销/禁用)同步调用:撤销下一次请求即生效。 */
|
|
52
77
|
export function invalidateUser(userId: number): void {
|
|
53
|
-
cache
|
|
78
|
+
// 同一 uid 在各租户下的快照都要失效(见 cache 注释):漏掉 B 租户那张,
|
|
79
|
+
// 撤销在另一个租户里就还可能晚一个 TTL 才生效。
|
|
80
|
+
for (const key of cache.keys()) {
|
|
81
|
+
if (key.endsWith(`|${userId}`))
|
|
82
|
+
cache.delete(key);
|
|
83
|
+
}
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
export function invalidateAllUsers(): void {
|
|
@@ -67,6 +97,10 @@ export async function currentAccess(): Promise<AccessSnapshot> {
|
|
|
67
97
|
if (uid == null)
|
|
68
98
|
deny();
|
|
69
99
|
const snap = await snapshot(Number(uid));
|
|
100
|
+
// 伪造租户头:token 的 sub 在**当前租户**里查无此人(见 AccessSnapshot.exists 注释)。
|
|
101
|
+
// 与 403 forbidden 同一句话,不给探测者区分「人不存在」与「没权限」的材料。
|
|
102
|
+
if (!snap.exists)
|
|
103
|
+
deny();
|
|
70
104
|
if (snap.forbidden)
|
|
71
105
|
deny();
|
|
72
106
|
if (snap.mustChange)
|
|
@@ -81,6 +115,27 @@ export async function assertPermission(code: string): Promise<void> {
|
|
|
81
115
|
deny();
|
|
82
116
|
}
|
|
83
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 租户自洽(§5.3 第四层):token 的 sub 是否存在于**当前租户**。
|
|
120
|
+
* 唯一现实成因是伪造 `X-TENANT-ID`——`users` 已租户化,构造器按头值注入,越权即「查无此行」。
|
|
121
|
+
*
|
|
122
|
+
* 为什么单独一个出口:那些「登录即可、不挂权限点」的端点(人员目录、通知、我的资料)
|
|
123
|
+
* 不走 `currentAccess()`(它还会判 must_change/forbidden),但它们同样读租户数据——
|
|
124
|
+
* 少了这一步,伪造头的人能读到别家企业的广播通知与人员目录。
|
|
125
|
+
*/
|
|
126
|
+
export async function isSelfTenant(): Promise<boolean> {
|
|
127
|
+
const uid = http.user?.id;
|
|
128
|
+
if (uid == null)
|
|
129
|
+
return false;
|
|
130
|
+
return (await snapshot(Number(uid))).exists;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `isSelfTenant` 的断言形态(respond 风格端点用;json.fail 风格的端点直接判 `isSelfTenant()`) */
|
|
134
|
+
export async function assertTenantSelf(): Promise<void> {
|
|
135
|
+
if (!(await isSelfTenant()))
|
|
136
|
+
deny();
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/** 系统管理员及以上(superadmin 或 admin)。 */
|
|
85
140
|
export async function assertSystemAdmin(): Promise<void> {
|
|
86
141
|
const { roles } = await currentAccess();
|
|
@@ -24,7 +24,11 @@ function pageArgs(input: { page?: unknown, size?: unknown }): { page: number, si
|
|
|
24
24
|
return { page, size, offset: (page - 1) * size };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 双查询分页:listSql/countSql 共用 params(调用方同一 WHERE),limit/offset 参数化(三方言安全)。
|
|
29
|
+
* 多租户红线:本函数是裸 SQL 直通,`tenant.enable` + `sql_guard: deny` 下两条 SQL 都必须显式写
|
|
30
|
+
* `tenant_id` 且 params 里带上当前租户值,否则被守卫拒;新代码优先用下方 `pagedFactory`。
|
|
31
|
+
*/
|
|
28
32
|
export async function paged<T>(
|
|
29
33
|
listSql: string,
|
|
30
34
|
countSql: string,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { respond } from "../_shared/respond";
|
|
2
1
|
// 平台共享服务域 /api/_platform/contacts(D207:登录即可,不挂权限点,H12)。
|
|
3
2
|
// 读操作不记审计(设计 §4B);接口级限速(设计 line 199)经 rate.ts(B5 复用同源)。
|
|
4
3
|
// 单条详情走子组 /api/_platform/contacts/detail?id=(detail/api.ts,含 email/avatar)。
|
|
4
|
+
import { respond } from "../_shared/respond";
|
|
5
5
|
import { searchContacts } from "../_shared/service/domain/contacts";
|
|
6
|
+
import { assertTenantSelf } from "../_shared/service/infra/access";
|
|
6
7
|
import { rateLimit } from "../_shared/service/infra/rate";
|
|
7
8
|
|
|
8
9
|
export default {
|
|
@@ -14,6 +15,8 @@ export default {
|
|
|
14
15
|
json.fail(401, "unauthorized");
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
18
|
+
// 目录是租户数据:伪造 X-TENANT-ID 时必须 403,否则能列出别家企业的人
|
|
19
|
+
await assertTenantSelf();
|
|
17
20
|
await rateLimit(`contacts:${uid}`, 60, 60_000);
|
|
18
21
|
const q = http.query as Record<string, unknown>;
|
|
19
22
|
return searchContacts(q.keyword, { limit: q.limit });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { respond } from "../../_shared/respond";
|
|
2
2
|
// GET /api/_platform/contacts/detail?id= —— 单条详情(含 email/avatar,设计 §4B)。
|
|
3
3
|
import { contactDetail } from "../../_shared/service/domain/contacts";
|
|
4
|
+
import { assertTenantSelf } from "../../_shared/service/infra/access";
|
|
4
5
|
import { rateLimit } from "../../_shared/service/infra/rate";
|
|
5
6
|
|
|
6
7
|
export default {
|
|
@@ -11,6 +12,8 @@ export default {
|
|
|
11
12
|
json.fail(401, "unauthorized");
|
|
12
13
|
return;
|
|
13
14
|
}
|
|
15
|
+
// 详情跨人可读,故与目录同一条租户自洽约束(伪造头 → 403,不靠「查无此行」静默 404)
|
|
16
|
+
await assertTenantSelf();
|
|
14
17
|
await rateLimit(`contacts:${uid}`, 60, 60_000);
|
|
15
18
|
return contactDetail(Number((http.query as Record<string, unknown>).id));
|
|
16
19
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0015__tenant(mysql 方言):多租户地基(企微代开发 v3 Phase 0)
|
|
2
|
+
-- 表定性同 0015__tenant.sql 头注释(A 组 11 张租户化 / B 组 10 张共享)。
|
|
3
|
+
-- 差异:TEXT 列不能带字面默认值也不能直接做唯一键 → 租户列用 VARCHAR(255)(与 users.username
|
|
4
|
+
-- 的既有 mysql 写法一致,schema.yaml 仍声明 text,reconcile 按 text↔varchar 同源处理)。
|
|
5
|
+
ALTER TABLE users ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
6
|
+
ALTER TABLE users ADD COLUMN corp_id VARCHAR(255) NULL;
|
|
7
|
+
ALTER TABLE users DROP INDEX username;
|
|
8
|
+
ALTER TABLE users ADD UNIQUE KEY uk_users_tenant_username (tenant_id, username);
|
|
9
|
+
|
|
10
|
+
ALTER TABLE sys_oplog ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
11
|
+
ALTER TABLE sys_user_role ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
12
|
+
ALTER TABLE sys_refresh_token ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 0015__tenant(postgres 方言):多租户地基(企微代开发 v3 Phase 0)
|
|
2
|
+
-- 表定性同 0015__tenant.sql 头注释(A 组 11 张租户化 / B 组 10 张共享)。
|
|
3
|
+
-- pg 可直接丢弃列级 UNIQUE 约束后再建复合唯一键,不需要重建表。
|
|
4
|
+
ALTER TABLE users ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
5
|
+
ALTER TABLE users ADD COLUMN corp_id TEXT;
|
|
6
|
+
ALTER TABLE users DROP CONSTRAINT users_username_key;
|
|
7
|
+
ALTER TABLE users ADD CONSTRAINT users_tenant_username_key UNIQUE (tenant_id, username);
|
|
8
|
+
|
|
9
|
+
ALTER TABLE sys_oplog ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
10
|
+
ALTER TABLE sys_user_role ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
11
|
+
ALTER TABLE sys_refresh_token ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- 0015__tenant(sqlite 方言):多租户地基(企微代开发 v3 Phase 0)
|
|
2
|
+
-- 21 张表定性(判据:该表是否可能在无租户上下文时被访问)——
|
|
3
|
+
-- A 组 租户化 11:users / sys_oplog / sys_user_role / sys_refresh_token
|
|
4
|
+
-- / notifications / notifications_broadcast
|
|
5
|
+
-- / notifications_broadcast_read / notifications_broadcast_hidden
|
|
6
|
+
-- / home_pie / home_line / todos
|
|
7
|
+
-- B 组 共享 10:_oj_migrations / sys_setting / sys_solution_route / sys_role / sys_permission
|
|
8
|
+
-- / sys_role_permission / sys_rate_window / sys_login_throttle
|
|
9
|
+
-- / market_history / reg_source_seq
|
|
10
|
+
-- B 组须 schema.yaml `tenant: false` + config `tenant.shared_allow` 双声明才豁免守卫。
|
|
11
|
+
-- 存量数据一律落 'default' 租户,**不做**自动归户(升级后由管理端显式分配)。
|
|
12
|
+
--
|
|
13
|
+
-- users:唯一性从「username 全局唯一」收敛为「(tenant_id, username) 同租户唯一」。
|
|
14
|
+
-- sqlite 无法 ALTER 掉内联 UNIQUE 约束(sqlite_autoindex 不可 DROP)→ 重建表,其余表只加列。
|
|
15
|
+
CREATE TABLE users__tenant_new (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
18
|
+
corp_id TEXT,
|
|
19
|
+
username TEXT NOT NULL,
|
|
20
|
+
password_hash TEXT NOT NULL,
|
|
21
|
+
avatar_base64 TEXT NOT NULL DEFAULT '',
|
|
22
|
+
source TEXT NOT NULL DEFAULT 'local',
|
|
23
|
+
display_name TEXT,
|
|
24
|
+
must_change INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
is_forbidden INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
email TEXT,
|
|
27
|
+
phone_number TEXT,
|
|
28
|
+
description TEXT,
|
|
29
|
+
UNIQUE (tenant_id, username)
|
|
30
|
+
);
|
|
31
|
+
INSERT INTO users__tenant_new (
|
|
32
|
+
id, tenant_id, username, password_hash, avatar_base64, source, display_name,
|
|
33
|
+
must_change, is_forbidden, email, phone_number, description
|
|
34
|
+
)
|
|
35
|
+
SELECT id, 'default', username, password_hash, avatar_base64, source, display_name,
|
|
36
|
+
must_change, is_forbidden, email, phone_number, description
|
|
37
|
+
FROM users;
|
|
38
|
+
DROP TABLE users;
|
|
39
|
+
ALTER TABLE users__tenant_new RENAME TO users;
|
|
40
|
+
|
|
41
|
+
ALTER TABLE sys_oplog ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
42
|
+
-- user_id 是全局自增 users.id(跨租户不重复),故主键 (user_id, role_name) 无需扩容
|
|
43
|
+
ALTER TABLE sys_user_role ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
44
|
+
ALTER TABLE sys_refresh_token ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
# 元数据校验放行(journalHeadOf:深检 ledger 复核 + P3-11 预锁 guard 的真源),列与 oj 真表逐列对齐。
|
|
6
6
|
tables:
|
|
7
7
|
_oj_migrations:
|
|
8
|
+
# 共享表(B 组):oj 迁移 journal,跨租户唯一,不可能带租户上下文访问。
|
|
9
|
+
tenant: false
|
|
8
10
|
pk: [module, version]
|
|
9
11
|
columns:
|
|
10
12
|
module: {type: text, null: false}
|
|
@@ -16,6 +18,10 @@ tables:
|
|
|
16
18
|
pk: id
|
|
17
19
|
columns:
|
|
18
20
|
id: {type: integer, autoincrement: true}
|
|
21
|
+
# 租户维度(0015):外部身份按租户隔离,用户名唯一性收敛到 (tenant_id, username)。
|
|
22
|
+
tenant_id: {type: text, null: false}
|
|
23
|
+
# 企微业务键(D3/D6):tenant_id 是隔离判定列,corp_id 只做可读冗余,不参与判定。
|
|
24
|
+
corp_id: {type: text}
|
|
19
25
|
username: {type: text, null: false}
|
|
20
26
|
password_hash: {type: text, null: false}
|
|
21
27
|
avatar_base64: {type: text, null: false}
|
|
@@ -27,6 +33,9 @@ tables:
|
|
|
27
33
|
phone_number: {type: text}
|
|
28
34
|
description: {type: text}
|
|
29
35
|
sys_setting:
|
|
36
|
+
# 共享表(B 组 D8):登录方式发现 /system/login-methods 是未登录端点,此刻没有租户上下文;
|
|
37
|
+
# 且这些开关是部署级语义,不是每 corp 语义。
|
|
38
|
+
tenant: false
|
|
30
39
|
pk: key
|
|
31
40
|
columns:
|
|
32
41
|
key: {type: text, null: false}
|
|
@@ -45,6 +54,7 @@ tables:
|
|
|
45
54
|
pk: id
|
|
46
55
|
columns:
|
|
47
56
|
id: {type: integer, autoincrement: true}
|
|
57
|
+
tenant_id: {type: text, null: false}
|
|
48
58
|
create_time: {type: integer, null: false}
|
|
49
59
|
user_id: {type: integer, null: false}
|
|
50
60
|
username: {type: text, null: false}
|
|
@@ -55,6 +65,9 @@ tables:
|
|
|
55
65
|
detail: {type: text}
|
|
56
66
|
level: {type: integer, null: false}
|
|
57
67
|
sys_solution_route:
|
|
68
|
+
# 共享表(B 组):站点级路由/启用注册表(PK=path,安装/卸载/启停为站点操作),
|
|
69
|
+
# 语义上不存在按 corp 分叉,也不保证在租户上下文里被访问。
|
|
70
|
+
tenant: false
|
|
58
71
|
# 0010 重构后形状(站点级路由,path 主键;与 migrations/0010__sys_solution_route.sql 逐列一致)。
|
|
59
72
|
pk: path
|
|
60
73
|
columns:
|
|
@@ -67,6 +80,8 @@ tables:
|
|
|
67
80
|
deprecated: {type: integer, null: false}
|
|
68
81
|
enabled: {type: integer, null: false}
|
|
69
82
|
sys_role:
|
|
83
|
+
# 共享表(B 组):框架角色目录(内置角色 + 权限码由模块声明同步而来),分配关系在 A 组。
|
|
84
|
+
tenant: false
|
|
70
85
|
pk: name
|
|
71
86
|
columns:
|
|
72
87
|
name: {type: text, null: false}
|
|
@@ -76,6 +91,7 @@ tables:
|
|
|
76
91
|
builtin: {type: integer, null: false}
|
|
77
92
|
deprecated: {type: integer, null: false}
|
|
78
93
|
sys_permission:
|
|
94
|
+
tenant: false
|
|
79
95
|
pk: code
|
|
80
96
|
columns:
|
|
81
97
|
code: {type: text, null: false}
|
|
@@ -83,6 +99,7 @@ tables:
|
|
|
83
99
|
title_key: {type: text}
|
|
84
100
|
deprecated: {type: integer, null: false}
|
|
85
101
|
sys_role_permission:
|
|
102
|
+
tenant: false
|
|
86
103
|
pk: [role_name, permission_code]
|
|
87
104
|
columns:
|
|
88
105
|
role_name: {type: text, null: false}
|
|
@@ -90,11 +107,13 @@ tables:
|
|
|
90
107
|
sys_user_role:
|
|
91
108
|
pk: [user_id, role_name]
|
|
92
109
|
columns:
|
|
110
|
+
tenant_id: {type: text, null: false}
|
|
93
111
|
user_id: {type: integer, null: false}
|
|
94
112
|
role_name: {type: text, null: false}
|
|
95
113
|
sys_refresh_token:
|
|
96
114
|
pk: token_hash
|
|
97
115
|
columns:
|
|
116
|
+
tenant_id: {type: text, null: false}
|
|
98
117
|
token_hash: {type: text, null: false}
|
|
99
118
|
user_id: {type: integer, null: false}
|
|
100
119
|
expires_at: {type: integer, null: false}
|
|
@@ -103,12 +122,15 @@ tables:
|
|
|
103
122
|
# 限速/登录节流状态表(V9__sys_throttle 经迁移创建;rate.ts 经 db.table() 访问,
|
|
104
123
|
# 须在此声明 + 在 manifest.yaml tables 登记,否则 oj 表元数据校验报 unknown table)。
|
|
105
124
|
sys_rate_window:
|
|
125
|
+
# 共享表(B 组):限速窗口按调用面计数(IP/账号维度),登录前就要用,无租户上下文。
|
|
126
|
+
tenant: false
|
|
106
127
|
pk: rate_key
|
|
107
128
|
columns:
|
|
108
129
|
rate_key: {type: text, null: false}
|
|
109
130
|
window_start: {type: integer, null: false}
|
|
110
131
|
count: {type: integer, null: false}
|
|
111
132
|
sys_login_throttle:
|
|
133
|
+
tenant: false
|
|
112
134
|
pk: pair_key
|
|
113
135
|
columns:
|
|
114
136
|
pair_key: {type: text, null: false}
|
|
@@ -117,6 +139,9 @@ tables:
|
|
|
117
139
|
# 市场操作历史(P4 上收 _platform):install/enable/disable/uninstall/upgrade/rollback 六类动作留痕。
|
|
118
140
|
# 写入由平台编排面(_lifecycle/history.ts recordHistory)落到本表;市场页只读消费。
|
|
119
141
|
market_history:
|
|
142
|
+
# 共享表(B 组):解决方案安装/升级/回滚是**站点级**操作(装一次全站生效),
|
|
143
|
+
# 按租户隔离会把同一批站点事件割裂成互不可见的碎片。
|
|
144
|
+
tenant: false
|
|
120
145
|
pk: id
|
|
121
146
|
columns:
|
|
122
147
|
id: {type: integer, autoincrement: true}
|
|
@@ -130,6 +155,9 @@ tables:
|
|
|
130
155
|
# REG-CS2 索引新鲜度:站点按源记住见过的最大 seq(防回放/降级,回退即拒)。
|
|
131
156
|
# 读写由 _lifecycle/source-seq.ts 落到本表;source_url 为业务主键(无自增 id)。
|
|
132
157
|
reg_source_seq:
|
|
158
|
+
# 共享表(B 组):索引新鲜度水位属于**站点**(受害者是站点,防回放/降级),
|
|
159
|
+
# 与租户无关;source-seq.ts 的设计注释即写明「记在站点 DB」。
|
|
160
|
+
tenant: false
|
|
133
161
|
pk: source_url
|
|
134
162
|
columns:
|
|
135
163
|
source_url: {type: text, null: false}
|
|
@@ -28,6 +28,9 @@ INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_va
|
|
|
28
28
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('framework.market_cdn_url', 'framework', 'security', 'string', '/cdn', '/cdn', 'system:setting.market_cdn_url', 0, 0);
|
|
29
29
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('ldap.enabled', 'ldap', 'login', 'bool', '0', '0', 'system:setting.ldap_enabled', 0, 0);
|
|
30
30
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('sso.enabled', 'sso', 'login', 'bool', '0', '0', 'system:setting.sso_enabled', 0, 0);
|
|
31
|
+
-- 企微代开发(登录方式 + 代开发后台总开关):默认关。开启前须先在 config.yaml vars 填 6 项部署常量,
|
|
32
|
+
-- 否则登录页会出现企微 tab 但点进去 400「企业微信未配置」(宁可报错也不静默隐藏,便于运维定位)。
|
|
33
|
+
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('wecom.enabled', 'wecom', 'login', 'bool', '0', '0', 'system:setting.wecom_enabled', 0, 0);
|
|
31
34
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('ldap_base_dn', 'ldap', 'ldap', 'string', '', '', 'system:setting.ldap_base_dn', 0, 0);
|
|
32
35
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('ldap_uid_attr', 'ldap', 'ldap', 'string', 'uid', 'uid', 'system:setting.ldap_uid_attr', 0, 0);
|
|
33
36
|
INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('ldap_email_attr', 'ldap', 'ldap', 'string', 'mail', 'mail', 'system:setting.ldap_email_attr', 0, 0);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 代开发后台(服务商运营面)的领域逻辑:企业应用清单、启停、开通入口。
|
|
2
|
+
//
|
|
3
|
+
// 三个反常识点,都是踩过的坑:
|
|
4
|
+
// 1. **不读部署常量也能停用**:`wecom_base_url` 配错时正是最需要「先把这个企业停掉」的时刻,
|
|
5
|
+
// 所以清单与启停都不依赖 `readWecomConfig`,只有开通入口要(它得调企微换预授权码)。
|
|
6
|
+
// 2. **不能用 `asTenant` 清会话**:管理员请求自带自己的租户头(多半是 `default`),
|
|
7
|
+
// 而 `asTenant` 只能在「请求还没有租户」时调用,否则抛错。故吊销目标企业会话走带
|
|
8
|
+
// `tenant_id = ?` 的裸 SQL(守卫是 check-on-miss,带了租户条件即放行)。
|
|
9
|
+
// 3. **停用单个应用 ≠ 停用企业**:会话是按租户(= 企业)发的,一个企业可能有测试/正式两个应用。
|
|
10
|
+
// 只有该企业**再没有启用中的应用**时才吊销会话——否则停用测试应用会把正式环境的人踢下线。
|
|
11
|
+
import type { WecomClient } from "./client";
|
|
12
|
+
import type { WecomConfig } from "./config";
|
|
13
|
+
import type { WecomStoreDeps } from "./store";
|
|
14
|
+
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
15
|
+
import { eventUrlOf, installUrlOf } from "./config";
|
|
16
|
+
import { corpScope, getSuiteTicket, revokeTenantSessions, tenantOfCorp } from "./store";
|
|
17
|
+
|
|
18
|
+
export interface WecomAdminDeps {
|
|
19
|
+
client: WecomClient
|
|
20
|
+
store: WecomStoreDeps
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 清单行:不含 `permanent_code_enc`(secret 不出库,连超级管理员也不给看) */
|
|
24
|
+
export interface AppRow {
|
|
25
|
+
suite_id: string
|
|
26
|
+
corp_id: string
|
|
27
|
+
agent_id: string
|
|
28
|
+
auth_corp_id: string
|
|
29
|
+
env: string
|
|
30
|
+
corp_name: string | null
|
|
31
|
+
status: string
|
|
32
|
+
installed_at: number
|
|
33
|
+
update_time: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function listApps(): Promise<AppRow[]> {
|
|
37
|
+
const rows = await db.table("wecom_app_profile")
|
|
38
|
+
.select(["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "corp_name", "status", "installed_at", "update_time"])
|
|
39
|
+
.all() as unknown as AppRow[];
|
|
40
|
+
// 稳定序:先按企业、再按应用;同企业多环境并排,运维一眼能看出「哪两个是同一家」
|
|
41
|
+
return rows.sort((a, b) => a.corp_id.localeCompare(b.corp_id) || a.agent_id.localeCompare(b.agent_id));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StatusResult {
|
|
45
|
+
/** 影响行数(0 = 该企业没开通这个应用) */
|
|
46
|
+
updated: number
|
|
47
|
+
/** 本次顺带吊销的 refresh token 数(0 = 还有别的应用在用,或本来就没有会话) */
|
|
48
|
+
revokedSessions: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 启用/停用某个企业的某个应用。
|
|
53
|
+
* 停用语义包含「不再发放新 token 且旧 refresh 失效(当企业整体停用时)」——授权与会话必须同频。
|
|
54
|
+
*/
|
|
55
|
+
export async function setAppStatus(
|
|
56
|
+
corpId: string,
|
|
57
|
+
agentId: string,
|
|
58
|
+
status: "active" | "disabled",
|
|
59
|
+
deps: WecomAdminDeps,
|
|
60
|
+
): Promise<StatusResult> {
|
|
61
|
+
const scope = {
|
|
62
|
+
and: [
|
|
63
|
+
corpScope(corpId),
|
|
64
|
+
{ field: "agent_id", op: "eq", value: agentId },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
const updated = Number(await db.table("wecom_app_profile").update({ status, update_time: deps.store.now() }).where(scope).run());
|
|
68
|
+
if (!updated)
|
|
69
|
+
throw Object.assign(new Error("该企业未开通此应用(或企业代码/应用 id 不匹配)"), { status: 404 });
|
|
70
|
+
|
|
71
|
+
// secret 可能仍在另一状态被缓存:无论启停都先让企业 token 失效,下一次登录重新换
|
|
72
|
+
await deps.client.invalidateCorpToken(corpId, agentId);
|
|
73
|
+
if (status === "active")
|
|
74
|
+
return { updated, revokedSessions: 0 };
|
|
75
|
+
|
|
76
|
+
const others = await db.table("wecom_app_profile").select(["agent_id"]).where({
|
|
77
|
+
and: [
|
|
78
|
+
corpScope(corpId),
|
|
79
|
+
{ field: "status", op: "eq", value: "active" },
|
|
80
|
+
],
|
|
81
|
+
}).all();
|
|
82
|
+
if (others.length)
|
|
83
|
+
return { updated, revokedSessions: 0 };
|
|
84
|
+
|
|
85
|
+
const revoked = await revokeTenantSessions(tenantOfCorp(corpId));
|
|
86
|
+
invalidateAllUsers();
|
|
87
|
+
return { updated, revokedSessions: revoked };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface InstallResult {
|
|
91
|
+
installUrl: string
|
|
92
|
+
/** 企微后台「指令回调 URL」应填这个(逐字) */
|
|
93
|
+
callbackUrl: string
|
|
94
|
+
expiresIn: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 生成开通入口:suite_access_token → 预授权码 → install URL(企业管理员点开即进入授权安装向导) */
|
|
98
|
+
export async function createInstall(
|
|
99
|
+
cfg: WecomConfig,
|
|
100
|
+
client: WecomClient,
|
|
101
|
+
store: WecomStoreDeps,
|
|
102
|
+
state: string,
|
|
103
|
+
): Promise<InstallResult> {
|
|
104
|
+
const ticket = await getSuiteTicket(cfg.suiteId, store);
|
|
105
|
+
const token = await client.suiteAccessToken(cfg.suiteId, cfg.suiteSecret, ticket.ticket);
|
|
106
|
+
const pre = await client.preAuthCode(token);
|
|
107
|
+
return {
|
|
108
|
+
installUrl: installUrlOf(cfg, pre.code, state),
|
|
109
|
+
callbackUrl: eventUrlOf(cfg),
|
|
110
|
+
expiresIn: pre.expiresIn,
|
|
111
|
+
};
|
|
112
|
+
}
|