@oj-module/cli 0.2.42 → 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/__tests__/verify.test.ts +32 -1
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/verify.ts +7 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +111 -101
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// 企微 API 客户端(代开发三级凭证链 + 网页授权换 userid)。
|
|
2
|
+
//
|
|
3
|
+
// 凭证链(devkit 未覆盖,见 docs/plans/20261030-1845-wecom-daikaifa-login-plan.md §5.4):
|
|
4
|
+
// suite_ticket(企微推) + suite_secret → suite_access_token(套件级,2h)
|
|
5
|
+
// suite_access_token + auth_code → permanent_code(= 代开发应用的 secret,属于 (suite, corp))
|
|
6
|
+
// permanent_code + corpid → corp access_token(**与自建应用同一个 gettoken 接口**,2h)
|
|
7
|
+
// corp access_token + code → userid(网页授权 snsapi_base,与自建应用同一个 auth/getuserinfo)
|
|
8
|
+
// 代开发与第三方应用的分叉点只有两处:取永久授权码走 `/service/v2/get_permanent_code`;
|
|
9
|
+
// 企业 token 走 `gettoken` 而不是 `service/get_corp_token`。
|
|
10
|
+
//
|
|
11
|
+
// 缓存:两段 token 都进 kv(oj 有 Redis 就跨进程,没有就进程内),TTL = expires_in-300。
|
|
12
|
+
// 失效:40001/42001 视为 token 失效 → 删缓存重取一次(企微侧提前失效是常态,不重试会把登录打成 502)。
|
|
13
|
+
// 错误两态红线(与 ldap-login 同):上游网络/协议/企微错误码 → 502(可重试语义);
|
|
14
|
+
// 用户凭据层面(code 无效/不在可见范围)→ 401(不区分原因,防探测)。
|
|
15
|
+
|
|
16
|
+
const API_BASE = "https://qyapi.weixin.qq.com";
|
|
17
|
+
const TIMEOUT_MS = 5000;
|
|
18
|
+
const TOKEN_SKEW_SECS = 300;
|
|
19
|
+
|
|
20
|
+
export interface WecomHttp {
|
|
21
|
+
getJson: (url: string) => Promise<Record<string, unknown>>
|
|
22
|
+
postJson: (url: string, body: unknown) => Promise<Record<string, unknown>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WecomCache {
|
|
26
|
+
get: (key: string) => Promise<string | null>
|
|
27
|
+
set: (key: string, value: string, ttlSecs: number) => Promise<unknown>
|
|
28
|
+
del: (key: string) => Promise<unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WecomClientDeps {
|
|
32
|
+
http: WecomHttp
|
|
33
|
+
cache: WecomCache
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function runtimeHttp(): WecomHttp {
|
|
37
|
+
async function call(url: string, init: RequestInit): Promise<Record<string, unknown>> {
|
|
38
|
+
let res: Response;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
throw upstream(`企微接口不可达:${(e as Error).message}`);
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw upstream(`企微接口 HTTP ${res.status}`);
|
|
47
|
+
try {
|
|
48
|
+
return await res.json() as Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw upstream("企微接口返回非 JSON");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
getJson: url => call(url, { method: "GET" }),
|
|
56
|
+
postJson: (url, body) => call(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}) }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runtimeCache(): WecomCache {
|
|
61
|
+
return {
|
|
62
|
+
get: key => kv.get(key),
|
|
63
|
+
// kv 没有「带 TTL 的写」,只能写值再设过期——两步之间进程退出、或 expire 失败,
|
|
64
|
+
// 这个键就变成「永不过期」:corp secret 轮换后旧 token 仍被当作新鲜的,
|
|
65
|
+
// 表现是长时间 502 而日志一片安静。故显式兜底:设不上过期就删键(宁可每请求回源)。
|
|
66
|
+
set: async (key, value, ttlSecs) => {
|
|
67
|
+
await kv.set(key, value);
|
|
68
|
+
if (!(await kv.expire(key, ttlSecs))) {
|
|
69
|
+
log.warn("wecom token cached without ttl", "key", key);
|
|
70
|
+
await kv.del(key);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
del: key => kv.del(key),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function upstream(message: string): Error {
|
|
78
|
+
return Object.assign(new Error(message), { status: 502 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** code 无效 / 不在应用可见范围 / 非企业成员:统一 401(不泄露具体原因,§5.3 枚举风险) */
|
|
82
|
+
function rejectCode(): never {
|
|
83
|
+
throw Object.assign(new Error("登录凭据无效或不在应用可见范围内"), { status: 401 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 企微错误码 → 异常。`errcode` 缺失视为成功(get_permanent_code 的历史行为)。 */
|
|
87
|
+
function assertOk(res: Record<string, unknown>, what: string): void {
|
|
88
|
+
const code = res.errcode;
|
|
89
|
+
if (code == null || Number(code) === 0)
|
|
90
|
+
return;
|
|
91
|
+
throw upstream(`企微 ${what} 失败:errcode=${String(code)} ${String(res.errmsg ?? "")}`.trimEnd());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TOKEN_EXPIRED = new Set([40001, 40014, 42001]);
|
|
95
|
+
|
|
96
|
+
export function createWecomClient(deps: WecomClientDeps) {
|
|
97
|
+
const { http, cache } = deps;
|
|
98
|
+
|
|
99
|
+
async function cachedToken(key: string, load: () => Promise<{ token: string, expiresIn: number }>): Promise<string> {
|
|
100
|
+
const hit = await cache.get(key);
|
|
101
|
+
if (hit)
|
|
102
|
+
return hit;
|
|
103
|
+
const { token, expiresIn } = await load();
|
|
104
|
+
const ttl = Math.max(60, Math.floor(expiresIn) - TOKEN_SKEW_SECS);
|
|
105
|
+
await cache.set(key, token, ttl);
|
|
106
|
+
return token;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 套件 access_token:需要 suite_ticket(过期/缺失由调用方先拦) */
|
|
110
|
+
async function suiteAccessToken(suiteId: string, suiteSecret: string, suiteTicket: string): Promise<string> {
|
|
111
|
+
return cachedToken(`wecom:suite_token:${suiteId}`, async () => {
|
|
112
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_suite_token`, {
|
|
113
|
+
suite_id: suiteId,
|
|
114
|
+
suite_secret: suiteSecret,
|
|
115
|
+
suite_ticket: suiteTicket,
|
|
116
|
+
});
|
|
117
|
+
assertOk(res, "get_suite_token");
|
|
118
|
+
if (!res.suite_access_token)
|
|
119
|
+
throw upstream("企微 get_suite_token 未返回 suite_access_token");
|
|
120
|
+
return { token: String(res.suite_access_token), expiresIn: Number(res.expires_in ?? 7200) };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* auth_code → permanent_code(+ 企业信息 + 应用列表)。
|
|
126
|
+
* 代开发用 `/service/v2/get_permanent_code`:返回 is_customized_app=true,且**不返回 access_token**。
|
|
127
|
+
* auth_code 一次有效(10 分钟内),重放只会拿到企微错误码 → 502,由调用方丢弃本条事件。
|
|
128
|
+
*/
|
|
129
|
+
async function permanentCodeByAuthCode(suiteAccessToken: string, authCode: string): Promise<{
|
|
130
|
+
permanentCode: string
|
|
131
|
+
corpId: string
|
|
132
|
+
corpName: string | null
|
|
133
|
+
agentIds: string[]
|
|
134
|
+
state: string | null
|
|
135
|
+
}> {
|
|
136
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
|
|
137
|
+
assertOk(res, "get_permanent_code");
|
|
138
|
+
const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
|
|
139
|
+
const agentList = ((res.auth_info ?? {}) as Record<string, unknown>).agent;
|
|
140
|
+
const agentIds = Array.isArray(agentList)
|
|
141
|
+
? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
|
|
142
|
+
: [];
|
|
143
|
+
if (!res.permanent_code || !authCorp.corpid)
|
|
144
|
+
throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
|
|
145
|
+
if (agentIds.length === 0)
|
|
146
|
+
throw upstream("企微 get_permanent_code 未返回授权应用(auth_info.agent 为空)");
|
|
147
|
+
return {
|
|
148
|
+
permanentCode: String(res.permanent_code),
|
|
149
|
+
corpId: String(authCorp.corpid),
|
|
150
|
+
corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
|
|
151
|
+
agentIds,
|
|
152
|
+
state: res.state == null ? null : String(res.state),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 预授权码(开通入口用):套件级、10 分钟、一次有效,拼进 install URL 交给企业管理员点开。
|
|
158
|
+
* 与 permanent_code 无关——它只换「安装向导里那一屏同意」,真正的永久码仍由 create_auth 回调给。
|
|
159
|
+
*/
|
|
160
|
+
async function preAuthCode(suiteAccessToken: string): Promise<{ code: string, expiresIn: number }> {
|
|
161
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_pre_auth_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {});
|
|
162
|
+
assertOk(res, "get_pre_auth_code");
|
|
163
|
+
if (!res.pre_auth_code)
|
|
164
|
+
throw upstream("企微 get_pre_auth_code 未返回 pre_auth_code");
|
|
165
|
+
return { code: String(res.pre_auth_code), expiresIn: Number(res.expires_in ?? 600) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 企业 access_token(代开发 = 自建应用的 gettoken,corpsecret 就是 permanent_code) */
|
|
169
|
+
async function corpAccessToken(corpId: string, agentId: string, secret: string): Promise<string> {
|
|
170
|
+
return cachedToken(`wecom:corp_token:${corpId}:${agentId}`, async () => {
|
|
171
|
+
const res = await http.getJson(`${API_BASE}/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(secret)}`);
|
|
172
|
+
assertOk(res, "gettoken");
|
|
173
|
+
if (!res.access_token)
|
|
174
|
+
throw upstream("企微 gettoken 未返回 access_token");
|
|
175
|
+
return { token: String(res.access_token), expiresIn: Number(res.expires_in ?? 7200) };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function invalidateCorpToken(corpId: string, agentId: string): Promise<void> {
|
|
180
|
+
await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
|
|
185
|
+
*
|
|
186
|
+
* 三类失败一律返回 null,绝不抛出:套件没有通讯录读权限(60011/48002)、成员不在可见范围、
|
|
187
|
+
* 上游抖动。补名是体验优化,不能因为拿不到名字把登录打成 5xx——
|
|
188
|
+
* 调用方拿到 null 就用工号兜底。
|
|
189
|
+
* token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
|
|
190
|
+
*/
|
|
191
|
+
async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
193
|
+
let token: string;
|
|
194
|
+
try {
|
|
195
|
+
token = await corpAccessToken(corpId, agentId, secret);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
let res: Record<string, unknown>;
|
|
201
|
+
try {
|
|
202
|
+
res = await http.getJson(`${API_BASE}/cgi-bin/user/get?access_token=${encodeURIComponent(token)}&userid=${encodeURIComponent(userid)}`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const errcode = Number(res.errcode ?? 0);
|
|
208
|
+
if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
|
|
209
|
+
await invalidateCorpToken(corpId, agentId);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (errcode !== 0)
|
|
213
|
+
return null;
|
|
214
|
+
const name = res.name == null ? "" : String(res.name).trim();
|
|
215
|
+
return name || null;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
|
|
221
|
+
async function userIdByCode(corpId: string, agentId: string, secret: string, code: string): Promise<string> {
|
|
222
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
223
|
+
const token = await corpAccessToken(corpId, agentId, secret);
|
|
224
|
+
const res = await http.getJson(`${API_BASE}/cgi-bin/auth/getuserinfo?access_token=${encodeURIComponent(token)}&code=${encodeURIComponent(code)}`);
|
|
225
|
+
const errcode = Number(res.errcode ?? 0);
|
|
226
|
+
if (TOKEN_EXPIRED.has(errcode) && attempt === 0) {
|
|
227
|
+
await invalidateCorpToken(corpId, agentId);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (errcode !== 0)
|
|
231
|
+
rejectCode();
|
|
232
|
+
const userid = res.userid == null ? "" : String(res.userid);
|
|
233
|
+
if (!userid)
|
|
234
|
+
rejectCode(); // 非企业成员(外部联系人)时企微只给 openid,不认作本应用用户
|
|
235
|
+
return userid;
|
|
236
|
+
}
|
|
237
|
+
throw upstream("企微 token 反复失效(40001/42001)");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { suiteAccessToken, permanentCodeByAuthCode, preAuthCode, corpAccessToken, invalidateCorpToken, userIdByCode, userNameById };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type WecomClient = ReturnType<typeof createWecomClient>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 企微代开发「服务商侧」部署常量(config `vars:` 段,不进 DB、不进 sys_setting)。
|
|
2
|
+
//
|
|
3
|
+
// 多租户动态性边界:**套件(suite)是部署级的**——一个部署 = 一个服务商账号下的一套代开发模板
|
|
4
|
+
// (suite_id/suite_secret/callback token/EncodingAESKey 全在这套模板的后台里)。
|
|
5
|
+
// 「哪个企业装了哪个应用」不写配置:企微 create_auth 回调推 AuthCode → 换 permanent_code →
|
|
6
|
+
// 落 wecom_app_profile,新增企业只需企业管理员扫码授权,**不改配置、不重启**。
|
|
7
|
+
// 同一企业两个应用(测试/正式)也无需改配置:profile 主键含 agent_id(D5)。
|
|
8
|
+
//
|
|
9
|
+
// 与 ldap 同口径:启用开关走 sys_setting(`wecom.enabled`),凭据走部署常量。
|
|
10
|
+
|
|
11
|
+
import { b64ToBytes } from "./crypt";
|
|
12
|
+
|
|
13
|
+
export interface WecomConfig {
|
|
14
|
+
/** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
|
|
15
|
+
suiteId: string
|
|
16
|
+
suiteSecret: string
|
|
17
|
+
/** 回调签名 token(企微后台「Token」) */
|
|
18
|
+
token: string
|
|
19
|
+
/** 回调加密密钥(43 字符 EncodingAESKey) */
|
|
20
|
+
encodingAesKey: string
|
|
21
|
+
/** AES-GCM 落库密钥(hex/base64,16 或 32 字节):加密 permanent_code/secret */
|
|
22
|
+
encKey: string
|
|
23
|
+
/**
|
|
24
|
+
* 连接器基址(绝对地址,无尾斜杠),如 `https://site.example.com/api/auth/wecom`。
|
|
25
|
+
* 两个派生地址都由它拼出,避免「同一件事配两处、改一处漏一处」:
|
|
26
|
+
* · 指令回调 URL = `<base>/event` → **原样填进企微后台**(须逐字一致,含协议与路径)
|
|
27
|
+
* · 网页授权 redirect_uri = `<base>/callback`
|
|
28
|
+
*/
|
|
29
|
+
baseUrl: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REQUIRED: Array<[keyof WecomConfig, string]> = [
|
|
33
|
+
["suiteId", "wecom_suite_id"],
|
|
34
|
+
["suiteSecret", "wecom_suite_secret"],
|
|
35
|
+
["token", "wecom_callback_token"],
|
|
36
|
+
["encodingAesKey", "wecom_encoding_aes_key"],
|
|
37
|
+
["encKey", "wecom_enc_key"],
|
|
38
|
+
["baseUrl", "wecom_base_url"],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 落库密钥格式校验(fail-fast 在**读配置**处,而不是等 create_auth 时在 crypto 里炸):
|
|
43
|
+
* oj 的 aesGcmEncrypt 只收「16 或 32 字节的 hex/base64」,AES-192(24 字节)明确不支持。
|
|
44
|
+
* 最常见的配置事故是把 32 个「随意字符」当密钥——它按 base64 解出 24 字节,加密时才报错,
|
|
45
|
+
* 而那时已经是企业授权回调,排查成本最高。
|
|
46
|
+
*/
|
|
47
|
+
export function assertEncKey(encKey: string): void {
|
|
48
|
+
const hex = /^[0-9a-f]+$/i.test(encKey) && [32, 64].includes(encKey.length);
|
|
49
|
+
let ok = hex;
|
|
50
|
+
if (!ok) {
|
|
51
|
+
try {
|
|
52
|
+
ok = [16, 32].includes(b64ToBytes(encKey).length);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
ok = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!ok)
|
|
59
|
+
throw Object.assign(new Error("企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64"), { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读部署常量。缺任一必需项即 400(「未配置」是运维事实,不是服务端缺陷):
|
|
64
|
+
* 前半段(suite 凭据)缺失 → 企微登录整体不可用;encKey 缺失则 permanent_code 无法安全落库,
|
|
65
|
+
* 宁可拒服务,也不退化成明文存储(§7.5 数据合规红线)。
|
|
66
|
+
*/
|
|
67
|
+
export function readWecomConfig(): WecomConfig {
|
|
68
|
+
const cfg: WecomConfig = {
|
|
69
|
+
suiteId: vars.get("wecom_suite_id") ?? "",
|
|
70
|
+
suiteSecret: vars.get("wecom_suite_secret") ?? "",
|
|
71
|
+
token: vars.get("wecom_callback_token") ?? "",
|
|
72
|
+
encodingAesKey: vars.get("wecom_encoding_aes_key") ?? "",
|
|
73
|
+
encKey: vars.get("wecom_enc_key") ?? "",
|
|
74
|
+
baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
|
|
75
|
+
};
|
|
76
|
+
const missing = REQUIRED.filter(([field]) => !cfg[field]).map(([, name]) => name);
|
|
77
|
+
if (missing.length)
|
|
78
|
+
throw Object.assign(new Error(`企业微信未配置:config vars 缺少 ${missing.join(" / ")}`), { status: 400 });
|
|
79
|
+
if (!/^https?:\/\//.test(cfg.baseUrl))
|
|
80
|
+
throw Object.assign(new Error("企业微信配置错误:wecom_base_url 必须是绝对地址(企微回调与 redirect_uri 都要求)"), { status: 400 });
|
|
81
|
+
assertEncKey(cfg.encKey);
|
|
82
|
+
return cfg;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 企微后台「指令回调 URL」:原样填这个(base + /event) */
|
|
86
|
+
export function eventUrlOf(cfg: WecomConfig): string {
|
|
87
|
+
return `${cfg.baseUrl}/event`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 网页授权 redirect_uri(base + /callback) */
|
|
91
|
+
export function loginRedirectOf(cfg: WecomConfig): string {
|
|
92
|
+
return `${cfg.baseUrl}/callback`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 连接器在站点里的固定挂载后缀(模块 apiPrefix `/auth` + 平台 API 前缀 `/api`):`siteRootOf` 靠它剥离 */
|
|
96
|
+
const CONNECTOR_SUFFIX = "/api/auth/wecom";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 站点根(origin + 子路径前缀,无尾斜杠):302 回跳落到 SPA 时用。
|
|
100
|
+
*
|
|
101
|
+
* 不能只取 origin:子路径部署(oj `mounts: [{ prefix: /oj }]`)时 SPA 的登录页在 `/oj/login`,
|
|
102
|
+
* 回跳到 `/login` 会打到宿主机根的别的应用(或 404)。前缀不另配一个变量——它就等于
|
|
103
|
+
* `wecom_base_url` 去掉本连接器的固定后缀,配一处即可,且不匹配时**报错**而不是猜。
|
|
104
|
+
*/
|
|
105
|
+
export function siteRootOf(cfg: WecomConfig): string {
|
|
106
|
+
const url = new URL(cfg.baseUrl);
|
|
107
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
108
|
+
if (!path.endsWith(CONNECTOR_SUFFIX))
|
|
109
|
+
throw Object.assign(new Error(`企业微信配置错误:wecom_base_url 必须以 ${CONNECTOR_SUFFIX} 结尾(如 https://site.example.com/oj${CONNECTOR_SUFFIX})`), { status: 400 });
|
|
110
|
+
return `${url.origin}${path.slice(0, -CONNECTOR_SUFFIX.length)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 代开发「开通入口」:企业管理员点开 → 企微授权安装页。
|
|
115
|
+
* `redirect_uri` 落在站点根下的登录页(安装完回站点,指令回调已把 profile 落好)。
|
|
116
|
+
*/
|
|
117
|
+
export function installUrlOf(cfg: WecomConfig, preAuthCode: string, state: string): string {
|
|
118
|
+
const redirect = `${siteRootOf(cfg)}/login?wecom_installed=1`;
|
|
119
|
+
const q = new URLSearchParams({
|
|
120
|
+
suite_id: cfg.suiteId,
|
|
121
|
+
pre_auth_code: preAuthCode,
|
|
122
|
+
redirect_uri: redirect,
|
|
123
|
+
});
|
|
124
|
+
if (state)
|
|
125
|
+
q.set("state", state);
|
|
126
|
+
return `https://open.work.weixin.qq.com/3rdapp/install?${q.toString()}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 登录方式发现用:只看开关,不读凭据(登录页要先知道「有没有这个方式」) */
|
|
130
|
+
export async function wecomEnabled(): Promise<boolean> {
|
|
131
|
+
const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "wecom.enabled" }).all() as Array<{ value: string | null }>;
|
|
132
|
+
return rows[0]?.value === "1";
|
|
133
|
+
}
|