@oj-module/cli 0.2.47 → 0.2.49
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 +1 -1
- package/devkit/SKILL.md +23 -4
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +221 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +242 -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__/build-backend.test.ts +80 -0
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/build.ts +50 -0
- 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 +280 -32
- 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 +188 -5
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +501 -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 +155 -35
- 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/login/web/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- 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 +146 -10
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +247 -19
- 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
|
@@ -7,6 +7,9 @@ tables:
|
|
|
7
7
|
- wecom_app_profile
|
|
8
8
|
- wecom_suite_ticket
|
|
9
9
|
- wecom_login_ticket
|
|
10
|
+
# 0002 组织架构同步:租户表(与上面三张共享表定性相反,见 schema.yaml)
|
|
11
|
+
- wecom_sync_job
|
|
12
|
+
- wecom_sync_lock
|
|
10
13
|
deps: # 两处都用:login/refresh 读 _platform.users(ownership_guard: deny,§3/S003);
|
|
11
14
|
# 跨模块别名 `#/_platform/…` 的依赖门禁(oj build S008)
|
|
12
15
|
_platform: ^0.1.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- 企微组织架构同步 mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:TEXT 主键须给长度(按业务长度收窄为 VARCHAR);INTEGER → BIGINT(秒级 epoch / 毫秒标记);
|
|
3
|
+
-- 索引写在 CREATE TABLE 内(MySQL 无 CREATE INDEX IF NOT EXISTS)。
|
|
4
|
+
ALTER TABLE wecom_app_profile ADD COLUMN auth_mode BIGINT NOT NULL DEFAULT 0;
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_job (
|
|
7
|
+
tenant_id VARCHAR(64) NOT NULL,
|
|
8
|
+
corp_id VARCHAR(64) NOT NULL,
|
|
9
|
+
agent_id VARCHAR(32) NOT NULL,
|
|
10
|
+
status VARCHAR(16) NOT NULL DEFAULT 'idle',
|
|
11
|
+
phase VARCHAR(16) NOT NULL DEFAULT 'departments',
|
|
12
|
+
cursor_text TEXT,
|
|
13
|
+
stats_text TEXT,
|
|
14
|
+
err_code VARCHAR(32),
|
|
15
|
+
err_detail VARCHAR(512),
|
|
16
|
+
started_at BIGINT NOT NULL DEFAULT 0,
|
|
17
|
+
finished_at BIGINT NOT NULL DEFAULT 0,
|
|
18
|
+
update_time BIGINT NOT NULL,
|
|
19
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
20
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_lock (
|
|
23
|
+
tenant_id VARCHAR(64) NOT NULL,
|
|
24
|
+
agent_id VARCHAR(32) NOT NULL,
|
|
25
|
+
owner VARCHAR(64) NOT NULL,
|
|
26
|
+
taken_at BIGINT NOT NULL,
|
|
27
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
28
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- 企微组织架构同步 pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:INTEGER → BIGINT(秒级 epoch / 毫秒标记);TEXT 在 pg 合法,键列保留 TEXT。
|
|
3
|
+
ALTER TABLE wecom_app_profile ADD COLUMN auth_mode BIGINT NOT NULL DEFAULT 0;
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_job (
|
|
6
|
+
tenant_id TEXT NOT NULL,
|
|
7
|
+
corp_id TEXT NOT NULL,
|
|
8
|
+
agent_id TEXT NOT NULL,
|
|
9
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
10
|
+
phase TEXT NOT NULL DEFAULT 'departments',
|
|
11
|
+
cursor_text TEXT,
|
|
12
|
+
stats_text TEXT,
|
|
13
|
+
err_code TEXT,
|
|
14
|
+
err_detail TEXT,
|
|
15
|
+
started_at BIGINT NOT NULL DEFAULT 0,
|
|
16
|
+
finished_at BIGINT NOT NULL DEFAULT 0,
|
|
17
|
+
update_time BIGINT NOT NULL,
|
|
18
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_lock (
|
|
22
|
+
tenant_id TEXT NOT NULL,
|
|
23
|
+
agent_id TEXT NOT NULL,
|
|
24
|
+
owner TEXT NOT NULL,
|
|
25
|
+
taken_at BIGINT NOT NULL,
|
|
26
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
27
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- 企微组织架构同步(B2):授权模式快照 + 同步任务 / 抢锁。
|
|
2
|
+
--
|
|
3
|
+
-- auth_mode 存在 profile 上而不是部署常量:它由 get_permanent_code 在「企业授权」时刻返回,
|
|
4
|
+
-- 是 (suite, corp, agent) 三元组的属性。**成员授权(1)下应用无任何部门权限**,通讯录类接口
|
|
5
|
+
-- 一律 60011(方案 §2.3),所以它是同步入口的准入判据——400 拒绝,而不是跑成一片 60011。
|
|
6
|
+
--
|
|
7
|
+
-- 两张同步表都是**租户表**(不进 config.yaml 的 tenant.shared_allow):同步只在调用方自己的
|
|
8
|
+
-- 租户内跑(§3 D7),跨租户只由 superadmin 面读元数据,且那也走显式 tenant_id 的裸 SQL。
|
|
9
|
+
--
|
|
10
|
+
-- 抢锁用「复合主键插入」:INSERT 撞唯一约束是唯一可靠的原子判据。反过来用 status='running'
|
|
11
|
+
-- 的读改写会留下竞态窗口,而 affected-rows 在三方言口径不一致、不能用来判成败(SKILL.md)。
|
|
12
|
+
ALTER TABLE wecom_app_profile ADD COLUMN auth_mode INTEGER NOT NULL DEFAULT 0;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_job (
|
|
15
|
+
tenant_id TEXT NOT NULL,
|
|
16
|
+
corp_id TEXT NOT NULL,
|
|
17
|
+
agent_id TEXT NOT NULL,
|
|
18
|
+
-- idle / running / done / failed / cancelled
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
20
|
+
-- departments / members / reconcile
|
|
21
|
+
phase TEXT NOT NULL DEFAULT 'departments',
|
|
22
|
+
-- 续跑游标(JSON):BFS 队列 + 已见部门 + 本轮进度标记。isolate 无定时器,进度只能靠调用方轮询推进
|
|
23
|
+
cursor_text TEXT,
|
|
24
|
+
-- 计数(JSON):departments / members / deptSkipped / memberSkipped
|
|
25
|
+
stats_text TEXT,
|
|
26
|
+
-- 只存脱敏后的结构化错码,绝不存异常原文(异常里可能夹带 URL 上的 token,§7)
|
|
27
|
+
err_code TEXT,
|
|
28
|
+
err_detail TEXT,
|
|
29
|
+
started_at INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
finished_at INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
update_time INTEGER NOT NULL,
|
|
32
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS wecom_sync_lock (
|
|
36
|
+
tenant_id TEXT NOT NULL,
|
|
37
|
+
agent_id TEXT NOT NULL,
|
|
38
|
+
owner TEXT NOT NULL,
|
|
39
|
+
taken_at INTEGER NOT NULL,
|
|
40
|
+
PRIMARY KEY (tenant_id, agent_id)
|
|
41
|
+
);
|
|
@@ -23,6 +23,8 @@ tables:
|
|
|
23
23
|
status: {type: text, null: false}
|
|
24
24
|
# permanent_code(= 代开发应用的 secret)的 AES-GCM 密文,禁止明文落库
|
|
25
25
|
permanent_code_enc: {type: text, null: false}
|
|
26
|
+
# 0=管理员授权 / 1=成员授权(0002):成员授权下应用无任何部门权限,组织架构同步必然 60011
|
|
27
|
+
auth_mode: {type: integer, null: false}
|
|
26
28
|
installed_at: {type: integer, null: false}
|
|
27
29
|
update_time: {type: integer, null: false}
|
|
28
30
|
wecom_suite_ticket:
|
|
@@ -46,3 +48,32 @@ tables:
|
|
|
46
48
|
expires_at: {type: integer, null: false}
|
|
47
49
|
used: {type: integer, null: false}
|
|
48
50
|
created_at: {type: integer, null: false}
|
|
51
|
+
# 组织架构同步(0002):**租户表**(不进 shared_allow)——同步只在调用方自己的租户内跑,
|
|
52
|
+
# 没有「查它时还不知道租户」的时刻;跨租户只由 superadmin 面读元数据。
|
|
53
|
+
wecom_sync_job:
|
|
54
|
+
pk: [tenant_id, agent_id]
|
|
55
|
+
columns:
|
|
56
|
+
tenant_id: {type: text, null: false}
|
|
57
|
+
corp_id: {type: text, null: false}
|
|
58
|
+
agent_id: {type: text, null: false}
|
|
59
|
+
# idle / running / done / failed / cancelled
|
|
60
|
+
status: {type: text, null: false}
|
|
61
|
+
# departments / members / reconcile
|
|
62
|
+
phase: {type: text, null: false}
|
|
63
|
+
# 续跑游标与计数都是 JSON 文本:字段演进不该每次都动三方言迁移
|
|
64
|
+
cursor_text: {type: text}
|
|
65
|
+
stats_text: {type: text}
|
|
66
|
+
# 只存脱敏后的结构化错码(异常原文可能夹带 URL 上的 token,§7)
|
|
67
|
+
err_code: {type: text}
|
|
68
|
+
err_detail: {type: text}
|
|
69
|
+
started_at: {type: integer, null: false}
|
|
70
|
+
finished_at: {type: integer, null: false}
|
|
71
|
+
update_time: {type: integer, null: false}
|
|
72
|
+
wecom_sync_lock:
|
|
73
|
+
pk: [tenant_id, agent_id]
|
|
74
|
+
columns:
|
|
75
|
+
tenant_id: {type: text, null: false}
|
|
76
|
+
agent_id: {type: text, null: false}
|
|
77
|
+
# 抢锁者标识(请求 id),只用于日志对账
|
|
78
|
+
owner: {type: text, null: false}
|
|
79
|
+
taken_at: {type: integer, null: false}
|
|
@@ -24,7 +24,7 @@ get.response = {
|
|
|
24
24
|
type: "array",
|
|
25
25
|
items: {
|
|
26
26
|
type: "object",
|
|
27
|
-
required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "installed_at", "update_time"],
|
|
27
|
+
required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "auth_mode", "installed_at", "update_time"],
|
|
28
28
|
properties: {
|
|
29
29
|
suite_id: { type: "string" },
|
|
30
30
|
corp_id: { type: "string" },
|
|
@@ -33,6 +33,8 @@ get.response = {
|
|
|
33
33
|
env: { type: "string" },
|
|
34
34
|
corp_name: { type: ["string", "null"] },
|
|
35
35
|
status: { type: "string", enum: ["active", "disabled"] },
|
|
36
|
+
// 权限三态(管理员授权 / 成员授权)——服务商一眼看出哪家企业「装了但同步不了」
|
|
37
|
+
auth_mode: { type: "number" },
|
|
36
38
|
installed_at: { type: "number" },
|
|
37
39
|
update_time: { type: "number" },
|
|
38
40
|
},
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { WecomConfig } from "../_shared/wecom/config";
|
|
2
|
+
import type { CallbackFields } from "../_shared/wecom/payload";
|
|
3
|
+
import type { ReplayClaim } from "../_shared/wecom/replay";
|
|
2
4
|
import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
3
5
|
// GET /api/auth/wecom/login?corp=&agent=&env= —— 发起企微授权(企微内静默 / 浏览器扫码)
|
|
4
6
|
// GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
|
|
@@ -18,20 +20,22 @@ import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } fro
|
|
|
18
20
|
import { createSession } from "../_shared/session";
|
|
19
21
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
20
22
|
import { loginRedirectOf, readCallbackConfig, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
|
|
21
|
-
import { unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
23
|
+
import { sha1Hex, unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
22
24
|
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
23
25
|
import {
|
|
24
26
|
assertUserActive,
|
|
25
27
|
buildAuthorizeUrl,
|
|
28
|
+
resolveWecomProfile,
|
|
26
29
|
ensureWecomUser,
|
|
27
30
|
LOGIN_TICKET_TTL_SECS,
|
|
28
31
|
loginLandingUrl,
|
|
29
32
|
putLoginState,
|
|
33
|
+
selfUpdate,
|
|
30
34
|
takeLoginState,
|
|
31
35
|
} from "../_shared/wecom/login";
|
|
32
|
-
import {
|
|
36
|
+
import { callbackFields, field } from "../_shared/wecom/payload";
|
|
37
|
+
import { guardReplay, runtimeReplayGate, ReplayAlreadyApplied, markApplied } from "../_shared/wecom/replay";
|
|
33
38
|
import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
|
|
34
|
-
import { xmlField } from "../_shared/wecom/xml";
|
|
35
39
|
|
|
36
40
|
// 模块级禁止 readWecomConfig()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
|
|
37
41
|
// 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
|
|
@@ -49,7 +53,7 @@ function query(name: string): string {
|
|
|
49
53
|
|
|
50
54
|
/** 纯文本 200(企微事件回执专用;信封会被判失败并触发重推) */
|
|
51
55
|
function plainText(body: string): void {
|
|
52
|
-
const stream = json.stream({ contentType: "text/plain; charset=utf-8" });
|
|
56
|
+
const stream = json.stream({ contentType: "text/plain; charset=utf-8", status: 200 });
|
|
53
57
|
stream.write(body);
|
|
54
58
|
stream.end();
|
|
55
59
|
}
|
|
@@ -58,6 +62,62 @@ function badRequest(message: string): Error {
|
|
|
58
62
|
return Object.assign(new Error(message), { status: 400 });
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 解不开时把「哪一侧配错」记进日志——只回一句 `pkcs7: bad padding` 等于没给线索。
|
|
67
|
+
*
|
|
68
|
+
* 能先验签通过才走到解密,说明后台 Token 与本部署一致;于是解不开只剩两类:
|
|
69
|
+
* · `bad padding` → **钥匙不对**:EncodingAESKey 与后台不一致(后台点过「随机生成」、或抄错/抄漏
|
|
70
|
+
* 一个字符),或这条推送其实来自另一个套件/模板(那把钥匙解不开这份密文)。
|
|
71
|
+
* `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹:改完 config 对不上就说明没生效(没重启/改错文件)。
|
|
72
|
+
* · `receiver mismatch` → **钥匙对、身份不对**。`receiveid` 已按候选试过(见 unwrapAccepting:
|
|
73
|
+
* suite_id + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,或这条推送
|
|
74
|
+
* 既不属本部署套件、receiveid 也不是它自己声称的身份(伪造/转发)。
|
|
75
|
+
*
|
|
76
|
+
* 注意 `envelopeToUser` **不等于** `configuredSuite` 是正常的:数据回调的 `ToUserName` 是 corpid。
|
|
77
|
+
*/
|
|
78
|
+
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: { encodingAesKey: string, suiteId: string }): void {
|
|
79
|
+
log.warn(
|
|
80
|
+
"wecom callback decrypt failed",
|
|
81
|
+
"reason",
|
|
82
|
+
(e as Error).message,
|
|
83
|
+
"envelopeToUser",
|
|
84
|
+
// URL 验证(GET)只有 query 里的 echostr,没有报文信封——那一侧记空串
|
|
85
|
+
envelope ? field(envelope, "ToUserName") : "",
|
|
86
|
+
"configuredSuite",
|
|
87
|
+
cfg.suiteId,
|
|
88
|
+
"aesKeyFp",
|
|
89
|
+
sha1Hex(cfg.encodingAesKey).slice(0, 8),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 按「可能合法的 receiveid」依次尝试解密。
|
|
95
|
+
*
|
|
96
|
+
* receiveid 的含义随场景变(90968 附注):**第三方事件的回调是 suiteid**(suite_ticket / create_auth
|
|
97
|
+
* / cancel_auth),**企业应用(含代开发应用的数据回调)是 corpid**(通讯录变更就走这条)。
|
|
98
|
+
* 我们一个端点吃两种,只认 suiteid 会把所有数据回调判成「解密失败」——而真正的错是「比错了值」。
|
|
99
|
+
*
|
|
100
|
+
* 只对 `receiver mismatch` 换下一个候选:`bad padding` 说明密钥就不对,换谁都解不开,直接抛。
|
|
101
|
+
*/
|
|
102
|
+
function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string[]): string {
|
|
103
|
+
let lastErr: unknown;
|
|
104
|
+
const tried = new Set<string>();
|
|
105
|
+
for (const receiver of candidates) {
|
|
106
|
+
if (!receiver || tried.has(receiver))
|
|
107
|
+
continue;
|
|
108
|
+
tried.add(receiver);
|
|
109
|
+
try {
|
|
110
|
+
return unwrapWecomMessage(encrypted, cfg.encodingAesKey, receiver);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (!/receiver mismatch/.test((e as Error).message))
|
|
114
|
+
throw e;
|
|
115
|
+
lastErr = e;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw lastErr ?? new Error("wecom: 没有可比对的 receiveid(suite_id 未配置?)");
|
|
119
|
+
}
|
|
120
|
+
|
|
61
121
|
/**
|
|
62
122
|
* 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
|
|
63
123
|
* 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
|
|
@@ -65,44 +125,66 @@ function badRequest(message: string): Error {
|
|
|
65
125
|
* 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
|
|
66
126
|
* cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
|
|
67
127
|
*/
|
|
68
|
-
async function decryptVerified(encrypted: string): Promise<{ cfg: WecomConfig, plain: string }> {
|
|
128
|
+
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ cfg: WecomConfig, plain: string, claim: ReplayClaim }> {
|
|
69
129
|
const cfg = readWecomConfig();
|
|
70
130
|
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
71
131
|
if (expected !== query("msg_signature"))
|
|
72
132
|
throw badRequest("回调签名校验失败");
|
|
73
|
-
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
133
|
+
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
74
134
|
try {
|
|
75
|
-
|
|
135
|
+
// 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
|
|
136
|
+
// 与 receiveid 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
|
|
137
|
+
return { cfg, plain: unwrapAccepting(encrypted, cfg, [cfg.suiteId, field(envelope, "ToUserName")]), claim };
|
|
76
138
|
}
|
|
77
139
|
catch (e) {
|
|
78
|
-
//
|
|
79
|
-
|
|
140
|
+
// 解不开的这一条不能占住 nonce:企微重推是它唯一的恢复路径,占住就把重推挡成 400
|
|
141
|
+
await claim.release();
|
|
142
|
+
// 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
143
|
+
noteDecryptFailure(e, envelope, cfg);
|
|
80
144
|
throw badRequest("回调消息解密失败");
|
|
81
145
|
}
|
|
82
146
|
}
|
|
83
147
|
|
|
148
|
+
/** 放回一次 nonce 占用;放回失败只告警——它不该盖住真正的失败原因 */
|
|
149
|
+
async function releaseClaim(claim: ReplayClaim | undefined): Promise<void> {
|
|
150
|
+
if (!claim)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
await claim.release();
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
log.warn("wecom replay nonce release failed", "reason", (e as Error).message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
84
160
|
const login = {
|
|
85
161
|
// 企微内打开(UA 含 wxwork)→ oauth2 静默授权;普通浏览器 → qrConnect 扫码。
|
|
86
162
|
// corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
|
|
87
163
|
async get() {
|
|
88
164
|
try {
|
|
89
165
|
const cfg = readWecomConfig();
|
|
166
|
+
const deps = runtimeStoreDeps(cfg);
|
|
167
|
+
|
|
90
168
|
const corp = query("corp");
|
|
91
169
|
const agent = query("agent");
|
|
92
|
-
if (!corp)
|
|
93
|
-
throw badRequest("缺少企业代码(corp):请使用企业管理员收到的登录链接,或在登录页填写企业代码");
|
|
94
170
|
|
|
95
|
-
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)
|
|
96
171
|
const ip = await clientIp();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
172
|
+
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)。
|
|
173
|
+
// corp 缺省时(代开发模板常只服务一家企业)自动从已安装档案解析,省去手动填企业代码,
|
|
174
|
+
// 也让「应用主页」可直接填 https://<域名>/login(无需 ${corpid} 占位符,企微后台会因非法 URL 字符拒收)。
|
|
175
|
+
let profile: { corpId: string; agentId: string; env: string };
|
|
176
|
+
if (corp) {
|
|
177
|
+
await guardLoginThrottle(ip, corp);
|
|
178
|
+
profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
|
|
179
|
+
// 只有「企业代码无效/多应用未指定」计失败;配置类错误(缺 vars)不该污染节流计数
|
|
180
|
+
if (e.status === 400)
|
|
181
|
+
await noteLoginFailed(ip, corp);
|
|
182
|
+
throw e;
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
profile = await resolveWecomProfile(agent || null);
|
|
186
|
+
await guardLoginThrottle(ip, profile.corpId);
|
|
187
|
+
}
|
|
106
188
|
|
|
107
189
|
const state = crypto.randomHex(16);
|
|
108
190
|
await putLoginState(state, { corpId: profile.corpId, agentId: profile.agentId, env: profile.env }, runtimeCache());
|
|
@@ -135,12 +217,13 @@ const callback = {
|
|
|
135
217
|
throw badRequest("登录状态缺失:请从企业微信重新进入");
|
|
136
218
|
|
|
137
219
|
const deps = runtimeStoreDeps(cfg);
|
|
138
|
-
const
|
|
220
|
+
const cache = runtimeCache();
|
|
221
|
+
const pending = await takeLoginState(state, cache);
|
|
139
222
|
const profile = await findProfile(pending.corpId, pending.agentId, deps);
|
|
140
223
|
// 顺序刚性:先绑租户再碰 users(JIT 建号是租户表写入,守卫 deny 下缺上下文即拒)
|
|
141
224
|
ensureTenant(tenantOfCorp(profile.corpId));
|
|
142
225
|
|
|
143
|
-
const client = createWecomClient({ http: runtimeHttp(), cache
|
|
226
|
+
const client = createWecomClient({ http: runtimeHttp(), cache });
|
|
144
227
|
const userid = await client.userIdByCode(profile.corpId, profile.agentId, profile.permanentCode, code);
|
|
145
228
|
// 名字是惰性取的:只有首次建档才多打一次 user/get(老用户零开销),拿不到就用工号兜底
|
|
146
229
|
const { uid, forbidden } = await ensureWecomUser(profile.corpId, userid, () =>
|
|
@@ -148,6 +231,15 @@ const callback = {
|
|
|
148
231
|
if (forbidden)
|
|
149
232
|
throw Object.assign(new Error("账号已被停用,请联系管理员"), { status: 403 });
|
|
150
233
|
|
|
234
|
+
// 轨一:登录自更新(方案 §6.1)。节流 24h、失败不影响登录;但企微侧已离职/禁用的
|
|
235
|
+
// 账号必须在这里拦下——本站的 is_forbidden 永远以管理员为准,不能被 IdP 反向覆盖。
|
|
236
|
+
const self = await selfUpdate(
|
|
237
|
+
{ uid, userid, corpId: profile.corpId, agentId: profile.agentId, permanentCode: profile.permanentCode },
|
|
238
|
+
{ client, cache, store: deps },
|
|
239
|
+
);
|
|
240
|
+
if (self.blocked)
|
|
241
|
+
throw Object.assign(new Error("账号在企业微信中已被停用或已退出企业,请联系企业管理员"), { status: 403 });
|
|
242
|
+
|
|
151
243
|
// 票据而不是 token 进 URL:token 会留在浏览器历史/Referer/反代日志里
|
|
152
244
|
const ticket = crypto.randomHex(32);
|
|
153
245
|
const tenant = tenantOfCorp(profile.corpId);
|
|
@@ -198,8 +290,9 @@ const event = {
|
|
|
198
290
|
// 不需要 suite_id——因此「配齐四项正确即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。
|
|
199
291
|
// 这正是打破鸡生蛋死锁(plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
200
292
|
//
|
|
201
|
-
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id
|
|
202
|
-
//
|
|
293
|
+
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验)。
|
|
294
|
+
// 加上「应用回调 URL 验证时 receiveid 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,
|
|
295
|
+
// 见下面 try 里的说明(签名是唯一闸门,本端点不落状态)。
|
|
203
296
|
async get() {
|
|
204
297
|
try {
|
|
205
298
|
const cfg = readCallbackConfig();
|
|
@@ -207,14 +300,20 @@ const event = {
|
|
|
207
300
|
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
208
301
|
if (expected !== query("msg_signature"))
|
|
209
302
|
throw badRequest("回调签名校验失败");
|
|
210
|
-
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
303
|
+
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
211
304
|
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
212
305
|
try {
|
|
213
|
-
|
|
306
|
+
// receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
|
|
307
|
+
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL」验证时是 corpid。
|
|
308
|
+
// 放宽是安全的:签名已过(token 只有本部署与企微知道),回包只是把解出的 echostr 原样返回,
|
|
309
|
+
// 不写任何状态;真正的 receiveid 比对在 POST 那条路径上(见 unwrapAccepting)。
|
|
310
|
+
plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, true));
|
|
214
311
|
}
|
|
215
312
|
catch (e) {
|
|
313
|
+
// 验证没通过就放回 nonce:企微后台点「保存」会连着验几次,占住会把后几次都挡成 400
|
|
314
|
+
await claim.release();
|
|
216
315
|
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
217
|
-
|
|
316
|
+
noteDecryptFailure(e, null, { encodingAesKey: cfg.encodingAesKey, suiteId });
|
|
218
317
|
throw badRequest("回调消息解密失败");
|
|
219
318
|
}
|
|
220
319
|
}
|
|
@@ -223,20 +322,41 @@ const event = {
|
|
|
223
322
|
}
|
|
224
323
|
},
|
|
225
324
|
|
|
226
|
-
//
|
|
227
|
-
// reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
325
|
+
// 回调(指令 + 应用数据):验签 → 解密 → 分发(suite_ticket / create_auth / change_auth /
|
|
326
|
+
// cancel_auth / reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
327
|
+
//
|
|
328
|
+
// 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
|
|
329
|
+
// 所以本函数只认字段名,不认格式。
|
|
228
330
|
async post() {
|
|
331
|
+
let claim: ReplayClaim | undefined;
|
|
229
332
|
try {
|
|
230
|
-
const
|
|
231
|
-
const encrypted =
|
|
333
|
+
const envelope = callbackFields(http.body);
|
|
334
|
+
const encrypted = field(envelope, "Encrypt");
|
|
232
335
|
if (!encrypted)
|
|
233
|
-
throw badRequest("回调报文缺少 Encrypt");
|
|
234
|
-
const
|
|
336
|
+
throw badRequest("回调报文缺少 Encrypt(不是企微回调,或中间件改写了请求体)");
|
|
337
|
+
const verified = await decryptVerified(encrypted, envelope);
|
|
338
|
+
claim = verified.claim;
|
|
339
|
+
|
|
235
340
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
236
|
-
await handleSuiteEvent(plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
341
|
+
await handleSuiteEvent(verified.plain, { cfg: verified.cfg, client, store: runtimeStoreDeps(verified.cfg) });
|
|
342
|
+
// 处理成功即标记 nonce 已落库:企微重推时命中即幂等回 success,解开「首推偏慢被企微判超时、
|
|
343
|
+
// 重推又被重放守卫挡 400」的死局——管理员端不再卡在「应用安装失败」。
|
|
344
|
+
await markApplied(query("nonce"), runtimeReplayGate()).catch(() => {});
|
|
237
345
|
plainText("success");
|
|
238
346
|
}
|
|
239
347
|
catch (e) {
|
|
348
|
+
// 重推且首推已成功落库:幂等回 success,管理员端不再卡在「应用安装失败」。
|
|
349
|
+
if (e instanceof ReplayAlreadyApplied) {
|
|
350
|
+
plainText("success");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// 处理失败必须放回 nonce:企微只在「拿不到 success」时重推,而那正是需要重推的时刻;
|
|
354
|
+
// 占住不放,重推会被自己的重放守卫判成 400,瞬态故障的恢复路径当场断掉。
|
|
355
|
+
await releaseClaim(claim);
|
|
356
|
+
// 企微侧只会显示「应用安装失败,联系服务商」,原因一概不透露——这里是唯一能查到
|
|
357
|
+
// 「哪一环拒了这条事件」的地方(缺这一行,这场失败在运维视角是零信息)。
|
|
358
|
+
const err = e as Error & { status?: number };
|
|
359
|
+
log.error("wecom event failed", "status", err.status ?? 500, "reason", err.message);
|
|
240
360
|
// 失败必须让企微看到非 success:它重推三次,正好覆盖「suite_ticket 未到/上游抖」这类瞬态
|
|
241
361
|
failWith(e);
|
|
242
362
|
}
|