@oj-module/cli 0.2.49 → 0.2.50
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 +8 -6
- package/devkit/SKILL.md +32 -12
- package/devkit/manual.md +6 -3
- package/devkit/wecom-daikaifa.md +95 -1
- package/devkit/wecom-internal-app.md +643 -0
- 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/init.ts +5 -4
- package/templates/config.yaml +28 -6
- package/templates/openapi.json +174 -2
- package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
- package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
- package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
- package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
- package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
- package/templates/src/auth/api/schema.yaml +2 -0
- package/templates/src/auth/api/wecom/admin/api.ts +112 -11
- package/templates/src/auth/api/wecom/api.ts +102 -48
- package/templates/src/auth/api/wecom/sync/api.ts +21 -5
- package/templates/src/auth/web/__client/api.schemas.ts +22 -1
- package/templates/src/auth/web/__client/api.ts +47 -1
- package/templates/src/system/web/locales/en-US.json +24 -1
- package/templates/src/system/web/locales/zh-CN.json +24 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/wecom/index.tsx +164 -46
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +7 -6
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +10 -4
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/vendor/host-versions.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CallbackCreds, WecomKind } from "../_shared/wecom/config";
|
|
2
|
+
import type { SelfApp } from "../_shared/wecom/login";
|
|
2
3
|
import type { CallbackFields } from "../_shared/wecom/payload";
|
|
3
4
|
import type { ReplayClaim } from "../_shared/wecom/replay";
|
|
4
5
|
import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
@@ -6,7 +7,7 @@ import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
|
6
7
|
// GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
|
|
7
8
|
// POST /api/auth/wecom/ticket {ticket} —— 一次性票据换 TokenBundle(token 不进 URL)
|
|
8
9
|
// GET /api/auth/wecom/event —— 企微回调 URL 验证(echostr 解出后原样回)
|
|
9
|
-
// POST /api/auth/wecom/event ——
|
|
10
|
+
// POST /api/auth/wecom/event —— 企微回调(代开发指令事件 / 自建应用通讯录变更)
|
|
10
11
|
//
|
|
11
12
|
// 三处纪律:
|
|
12
13
|
// 1. 两个 302 端点**不能**用 respond(它末尾无脑 json.ok,会把 302 覆盖成 200 信封)——
|
|
@@ -19,25 +20,36 @@ import { failWith, respond } from "#/_platform/_shared/respond";
|
|
|
19
20
|
import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
|
|
20
21
|
import { createSession } from "../_shared/session";
|
|
21
22
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
|
|
25
|
+
callbackCreds,
|
|
26
|
+
loginRedirectOf,
|
|
27
|
+
readBase,
|
|
28
|
+
readCorpConfig,
|
|
29
|
+
readSuiteConfig,
|
|
30
|
+
siteRootOf,
|
|
31
|
+
wecomKinds,
|
|
32
|
+
} from "../_shared/wecom/config";
|
|
33
|
+
import { handleContactChange } from "../_shared/wecom/contacts";
|
|
23
34
|
import { sha1Hex, unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
24
35
|
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
25
36
|
import {
|
|
26
37
|
assertUserActive,
|
|
27
38
|
buildAuthorizeUrl,
|
|
28
|
-
resolveWecomProfile,
|
|
29
39
|
ensureWecomUser,
|
|
30
40
|
LOGIN_TICKET_TTL_SECS,
|
|
31
41
|
loginLandingUrl,
|
|
32
42
|
putLoginState,
|
|
43
|
+
resolveWecomProfile,
|
|
44
|
+
|
|
33
45
|
selfUpdate,
|
|
34
46
|
takeLoginState,
|
|
35
47
|
} from "../_shared/wecom/login";
|
|
36
48
|
import { callbackFields, field } from "../_shared/wecom/payload";
|
|
37
|
-
import { guardReplay,
|
|
49
|
+
import { guardReplay, markApplied, ReplayAlreadyApplied, runtimeReplayGate } from "../_shared/wecom/replay";
|
|
38
50
|
import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
|
|
39
51
|
|
|
40
|
-
// 模块级禁止
|
|
52
|
+
// 模块级禁止 readBase()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
|
|
41
53
|
// 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
|
|
42
54
|
|
|
43
55
|
/** 请求头的读取按大小写不敏感处理(http.headers 的键大小写随客户端) */
|
|
@@ -62,6 +74,19 @@ function badRequest(message: string): Error {
|
|
|
62
74
|
return Object.assign(new Error(message), { status: 400 });
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
/**
|
|
78
|
+
* 本部署自己那家企业内部应用(配了 `wecom_corp_*` 才有)。
|
|
79
|
+
*
|
|
80
|
+
* 作用是给登录入口一个**确定的默认目标**,而不是去猜档案里哪一行是「本站自己」:共存部署里
|
|
81
|
+
* 档案表还有别家企业通过代开发装进来的行,猜的代价是本站 `/login` 反过来回 400。
|
|
82
|
+
*/
|
|
83
|
+
function corpSelf(): SelfApp | undefined {
|
|
84
|
+
if (!wecomKinds().includes("corp"))
|
|
85
|
+
return undefined;
|
|
86
|
+
const c = readCorpConfig();
|
|
87
|
+
return { corpId: c.corpId, agentId: c.agentId };
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
/**
|
|
66
91
|
* 解不开时把「哪一侧配错」记进日志——只回一句 `pkcs7: bad padding` 等于没给线索。
|
|
67
92
|
*
|
|
@@ -70,23 +95,26 @@ function badRequest(message: string): Error {
|
|
|
70
95
|
* 一个字符),或这条推送其实来自另一个套件/模板(那把钥匙解不开这份密文)。
|
|
71
96
|
* `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹:改完 config 对不上就说明没生效(没重启/改错文件)。
|
|
72
97
|
* · `receiver mismatch` → **钥匙对、身份不对**。`receiveid` 已按候选试过(见 unwrapAccepting:
|
|
73
|
-
*
|
|
74
|
-
*
|
|
98
|
+
* 凭据自己的 receiver + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,
|
|
99
|
+
* 或这条推送既不属本部署、receiveid 也不是它自己声称的身份(伪造/转发)。
|
|
75
100
|
*
|
|
76
|
-
*
|
|
101
|
+
* `kind` 必须记:共存部署里有两套钥匙,日志不写「这次用的是哪套」,排障就退化成猜。
|
|
102
|
+
* 注意 `envelopeToUser` **不等于** `configuredReceiver` 是正常的:数据回调的 `ToUserName` 是 corpid。
|
|
77
103
|
*/
|
|
78
|
-
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null,
|
|
104
|
+
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, creds: CallbackCreds): void {
|
|
79
105
|
log.warn(
|
|
80
106
|
"wecom callback decrypt failed",
|
|
81
107
|
"reason",
|
|
82
108
|
(e as Error).message,
|
|
109
|
+
"kind",
|
|
110
|
+
creds.kind,
|
|
83
111
|
"envelopeToUser",
|
|
84
112
|
// URL 验证(GET)只有 query 里的 echostr,没有报文信封——那一侧记空串
|
|
85
113
|
envelope ? field(envelope, "ToUserName") : "",
|
|
86
|
-
"
|
|
87
|
-
|
|
114
|
+
"configuredReceiver",
|
|
115
|
+
creds.receiver,
|
|
88
116
|
"aesKeyFp",
|
|
89
|
-
sha1Hex(
|
|
117
|
+
sha1Hex(creds.encodingAesKey).slice(0, 8),
|
|
90
118
|
);
|
|
91
119
|
}
|
|
92
120
|
|
|
@@ -94,12 +122,12 @@ function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: {
|
|
|
94
122
|
* 按「可能合法的 receiveid」依次尝试解密。
|
|
95
123
|
*
|
|
96
124
|
* receiveid 的含义随场景变(90968 附注):**第三方事件的回调是 suiteid**(suite_ticket / create_auth
|
|
97
|
-
* / cancel_auth
|
|
125
|
+
* / cancel_auth),**企业应用(含代开发应用的数据回调、自建应用的通讯录事件)是 corpid**。
|
|
98
126
|
* 我们一个端点吃两种,只认 suiteid 会把所有数据回调判成「解密失败」——而真正的错是「比错了值」。
|
|
99
127
|
*
|
|
100
128
|
* 只对 `receiver mismatch` 换下一个候选:`bad padding` 说明密钥就不对,换谁都解不开,直接抛。
|
|
101
129
|
*/
|
|
102
|
-
function unwrapAccepting(encrypted: string,
|
|
130
|
+
function unwrapAccepting(encrypted: string, creds: CallbackCreds, candidates: string[]): string {
|
|
103
131
|
let lastErr: unknown;
|
|
104
132
|
const tried = new Set<string>();
|
|
105
133
|
for (const receiver of candidates) {
|
|
@@ -107,7 +135,7 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
107
135
|
continue;
|
|
108
136
|
tried.add(receiver);
|
|
109
137
|
try {
|
|
110
|
-
return unwrapWecomMessage(encrypted,
|
|
138
|
+
return unwrapWecomMessage(encrypted, creds.encodingAesKey, receiver);
|
|
111
139
|
}
|
|
112
140
|
catch (e) {
|
|
113
141
|
if (!/receiver mismatch/.test((e as Error).message))
|
|
@@ -118,6 +146,14 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
118
146
|
throw lastErr ?? new Error("wecom: 没有可比对的 receiveid(suite_id 未配置?)");
|
|
119
147
|
}
|
|
120
148
|
|
|
149
|
+
/** 用哪一把钥匙能验过签名:**逐个试**(SHA1 一次,成本可忽略),命中即用它的钥匙解密 */
|
|
150
|
+
function matchCreds(encrypted: string): CallbackCreds | null {
|
|
151
|
+
const sig = query("msg_signature");
|
|
152
|
+
const ts = query("timestamp");
|
|
153
|
+
const nonce = query("nonce");
|
|
154
|
+
return callbackCreds().find(c => wecomSignature(c.token, ts, nonce, encrypted) === sig) ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
121
157
|
/**
|
|
122
158
|
* 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
|
|
123
159
|
* 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
|
|
@@ -125,22 +161,21 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
125
161
|
* 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
|
|
126
162
|
* cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
|
|
127
163
|
*/
|
|
128
|
-
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (expected !== query("msg_signature"))
|
|
164
|
+
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ kind: WecomKind, plain: string, claim: ReplayClaim }> {
|
|
165
|
+
const creds = matchCreds(encrypted);
|
|
166
|
+
if (!creds)
|
|
132
167
|
throw badRequest("回调签名校验失败");
|
|
133
168
|
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
134
169
|
try {
|
|
135
170
|
// 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
|
|
136
|
-
// 与
|
|
137
|
-
return {
|
|
171
|
+
// 与 receiver 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
|
|
172
|
+
return { kind: creds.kind, plain: unwrapAccepting(encrypted, creds, [creds.receiver, field(envelope, "ToUserName")]), claim };
|
|
138
173
|
}
|
|
139
174
|
catch (e) {
|
|
140
175
|
// 解不开的这一条不能占住 nonce:企微重推是它唯一的恢复路径,占住就把重推挡成 400
|
|
141
176
|
await claim.release();
|
|
142
177
|
// 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
143
|
-
noteDecryptFailure(e, envelope,
|
|
178
|
+
noteDecryptFailure(e, envelope, creds);
|
|
144
179
|
throw badRequest("回调消息解密失败");
|
|
145
180
|
}
|
|
146
181
|
}
|
|
@@ -162,8 +197,10 @@ const login = {
|
|
|
162
197
|
// corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
|
|
163
198
|
async get() {
|
|
164
199
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
// 登录只用「与来源无关」的两项(落库密钥 + 基址):自建模式没有 suite 凭据,
|
|
201
|
+
// 在这里读整套 suite 配置会把一条正常的登录打成 400。
|
|
202
|
+
const base = readBase();
|
|
203
|
+
const deps = runtimeStoreDeps(base);
|
|
167
204
|
|
|
168
205
|
const corp = query("corp");
|
|
169
206
|
const agent = query("agent");
|
|
@@ -172,7 +209,7 @@ const login = {
|
|
|
172
209
|
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)。
|
|
173
210
|
// corp 缺省时(代开发模板常只服务一家企业)自动从已安装档案解析,省去手动填企业代码,
|
|
174
211
|
// 也让「应用主页」可直接填 https://<域名>/login(无需 ${corpid} 占位符,企微后台会因非法 URL 字符拒收)。
|
|
175
|
-
let profile: { corpId: string
|
|
212
|
+
let profile: { corpId: string, agentId: string, env: string };
|
|
176
213
|
if (corp) {
|
|
177
214
|
await guardLoginThrottle(ip, corp);
|
|
178
215
|
profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
|
|
@@ -181,8 +218,9 @@ const login = {
|
|
|
181
218
|
await noteLoginFailed(ip, corp);
|
|
182
219
|
throw e;
|
|
183
220
|
});
|
|
184
|
-
}
|
|
185
|
-
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
profile = await resolveWecomProfile(agent || null, corpSelf());
|
|
186
224
|
await guardLoginThrottle(ip, profile.corpId);
|
|
187
225
|
}
|
|
188
226
|
|
|
@@ -191,7 +229,7 @@ const login = {
|
|
|
191
229
|
const url = buildAuthorizeUrl({
|
|
192
230
|
corpId: profile.corpId,
|
|
193
231
|
agentId: profile.agentId,
|
|
194
|
-
redirectUri: loginRedirectOf(
|
|
232
|
+
redirectUri: loginRedirectOf(base),
|
|
195
233
|
state,
|
|
196
234
|
inWecom: /wxwork/i.test(header("user-agent")),
|
|
197
235
|
});
|
|
@@ -207,7 +245,7 @@ const callback = {
|
|
|
207
245
|
// 企微回跳:state 一次性(kv 取即删)→ profile → 绑租户 → code 换 userid → JIT 建档 → 发票 → 302。
|
|
208
246
|
async get() {
|
|
209
247
|
try {
|
|
210
|
-
const
|
|
248
|
+
const base = readBase();
|
|
211
249
|
const code = query("code");
|
|
212
250
|
const state = query("state");
|
|
213
251
|
// 用户在企业微信里点了「拒绝」时企微不回 code,只回 state
|
|
@@ -216,7 +254,7 @@ const callback = {
|
|
|
216
254
|
if (!state)
|
|
217
255
|
throw badRequest("登录状态缺失:请从企业微信重新进入");
|
|
218
256
|
|
|
219
|
-
const deps = runtimeStoreDeps(
|
|
257
|
+
const deps = runtimeStoreDeps(base);
|
|
220
258
|
const cache = runtimeCache();
|
|
221
259
|
const pending = await takeLoginState(state, cache);
|
|
222
260
|
const profile = await findProfile(pending.corpId, pending.agentId, deps);
|
|
@@ -246,7 +284,7 @@ const callback = {
|
|
|
246
284
|
await issueLoginTicket(crypto.sha256Hex(ticket), uid, tenant, LOGIN_TICKET_TTL_SECS, deps);
|
|
247
285
|
await noteLoginSucceeded(await clientIp(), profile.corpId);
|
|
248
286
|
log.info("wecom login ok", "tenant", tenant, "agentId", profile.agentId);
|
|
249
|
-
json.redirect.found(loginLandingUrl({ origin: siteRootOf(
|
|
287
|
+
json.redirect.found(loginLandingUrl({ origin: siteRootOf(base), ticket, tenant }));
|
|
250
288
|
}
|
|
251
289
|
catch (e) {
|
|
252
290
|
failWith(e);
|
|
@@ -286,34 +324,35 @@ ticket.post.schema = {
|
|
|
286
324
|
const event = {
|
|
287
325
|
// 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)。
|
|
288
326
|
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
327
|
+
// 只依赖回调凭据(`callbackCreds()`):token/aeskey 由部署方与企微后台成对填写,不需要 suite_id——
|
|
328
|
+
// 因此「配齐即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。这正是打破鸡生蛋死锁
|
|
329
|
+
// (plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
330
|
+
//
|
|
331
|
+
// 共存部署有**两套**回调凭据(代开发的指令回调 / 自建应用的通讯录同步回调),企微用哪一套签名
|
|
332
|
+
// 取决于管理员在哪个后台点「保存」→ 逐个试签,命中哪套就用哪套钥匙解密。两套都没配才报错。
|
|
292
333
|
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
334
|
+
// URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验),加上「应用回调 URL 验证时 receiveid
|
|
335
|
+
// 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,见下面 try 里的说明
|
|
336
|
+
// (签名是唯一闸门,本端点不落状态)。
|
|
296
337
|
async get() {
|
|
297
338
|
try {
|
|
298
|
-
const cfg = readCallbackConfig();
|
|
299
339
|
const encrypted = query("echostr");
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
340
|
+
const creds = matchCreds(encrypted);
|
|
341
|
+
if (!creds)
|
|
302
342
|
throw badRequest("回调签名校验失败");
|
|
303
343
|
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
304
|
-
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
305
344
|
try {
|
|
306
345
|
// receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
|
|
307
|
-
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL
|
|
346
|
+
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL / 通讯录同步」验证时是 corpid。
|
|
308
347
|
// 放宽是安全的:签名已过(token 只有本部署与企微知道),回包只是把解出的 echostr 原样返回,
|
|
309
348
|
// 不写任何状态;真正的 receiveid 比对在 POST 那条路径上(见 unwrapAccepting)。
|
|
310
|
-
plainText(unwrapWecomMessage(encrypted,
|
|
349
|
+
plainText(unwrapWecomMessage(encrypted, creds.encodingAesKey, creds.receiver, true));
|
|
311
350
|
}
|
|
312
351
|
catch (e) {
|
|
313
352
|
// 验证没通过就放回 nonce:企微后台点「保存」会连着验几次,占住会把后几次都挡成 400
|
|
314
353
|
await claim.release();
|
|
315
354
|
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
316
|
-
noteDecryptFailure(e, null,
|
|
355
|
+
noteDecryptFailure(e, null, creds);
|
|
317
356
|
throw badRequest("回调消息解密失败");
|
|
318
357
|
}
|
|
319
358
|
}
|
|
@@ -322,8 +361,12 @@ const event = {
|
|
|
322
361
|
}
|
|
323
362
|
},
|
|
324
363
|
|
|
325
|
-
// 回调(指令 +
|
|
326
|
-
//
|
|
364
|
+
// 回调(指令 + 应用数据 + 通讯录同步):验签 → 解密 → 按**命中凭据的来源**分发。
|
|
365
|
+
// 回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
366
|
+
//
|
|
367
|
+
// 来源分派是共存的关键:suite 凭据只可能收到套件事件(suite_ticket / create_auth / change_auth /
|
|
368
|
+
// cancel_auth);corp 凭据只可能收到通讯录变更(企业管理员在「通讯录同步 → 接收事件服务器」填的本 URL),
|
|
369
|
+
// **没有**套件事件这回事,所以直接进 change_contact 分支,不进 handleSuiteEvent 的 switch。
|
|
327
370
|
//
|
|
328
371
|
// 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
|
|
329
372
|
// 所以本函数只认字段名,不认格式。
|
|
@@ -338,7 +381,18 @@ const event = {
|
|
|
338
381
|
claim = verified.claim;
|
|
339
382
|
|
|
340
383
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
341
|
-
|
|
384
|
+
if (verified.kind === "corp") {
|
|
385
|
+
// 自建应用没有套件流程:报文只可能是 change_contact(其余形态由 contacts.ts 记账后放过)
|
|
386
|
+
await handleContactChange(callbackFields(verified.plain), {
|
|
387
|
+
client,
|
|
388
|
+
store: runtimeStoreDeps(readBase()),
|
|
389
|
+
credentialKind: "corp",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const cfg = readSuiteConfig();
|
|
394
|
+
await handleSuiteEvent(verified.plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
395
|
+
}
|
|
342
396
|
// 处理成功即标记 nonce 已落库:企微重推时命中即幂等回 success,解开「首推偏慢被企微判超时、
|
|
343
397
|
// 重推又被重放守卫挡 400」的死局——管理员端不再卡在「应用安装失败」。
|
|
344
398
|
await markApplied(query("nonce"), runtimeReplayGate()).catch(() => {});
|
|
@@ -18,7 +18,7 @@ import { assertPermission, assertTenantSelf } from "#/_platform/_shared/service/
|
|
|
18
18
|
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
19
19
|
import { rateLimit } from "#/_platform/_shared/service/infra/rate";
|
|
20
20
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
21
|
-
import {
|
|
21
|
+
import { corpIdentity, readBase } from "../../_shared/wecom/config";
|
|
22
22
|
import { findProfile, probeProfile, runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
23
23
|
import { attachProfile, cancelSync, emptyView, startSync, syncStatus } from "../../_shared/wecom/sync";
|
|
24
24
|
|
|
@@ -44,12 +44,15 @@ const STATS = {
|
|
|
44
44
|
|
|
45
45
|
const VIEW = {
|
|
46
46
|
type: "object",
|
|
47
|
-
required: ["connected", "authMode", "profileStatus", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
47
|
+
required: ["connected", "authMode", "profileStatus", "agentId", "credentialKind", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
48
48
|
properties: {
|
|
49
49
|
// 授权事实(页面据此决定「整条隐藏 / 成员授权卡片 / 冻结黄条 / 正常同步栏」)
|
|
50
50
|
connected: { type: "boolean" },
|
|
51
51
|
authMode: { type: "number" },
|
|
52
52
|
profileStatus: { type: ["string", "null"] },
|
|
53
|
+
// 分辨锚点:多应用/共存企业下前端必须原样回传(C6)
|
|
54
|
+
agentId: { type: ["string", "null"] },
|
|
55
|
+
credentialKind: { type: ["string", "null"], enum: ["suite", "corp", null] },
|
|
53
56
|
status: { type: "string", enum: ["idle", "running", "done", "failed", "cancelled"] },
|
|
54
57
|
phase: { type: "string", enum: ["departments", "members", "reconcile"] },
|
|
55
58
|
stats: STATS,
|
|
@@ -80,6 +83,18 @@ function agentOf(raw: unknown): string | null {
|
|
|
80
83
|
return s.length > 0 ? s : null;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 未带 agent 时的兜底:**只有当本租户就是本部署自建的那家企业**才用配置里的 agent。
|
|
88
|
+
*
|
|
89
|
+
* 共存下的双应用租户(同一 corp 同时有代开发行与自建行)不带 agent 时探针只能 400,
|
|
90
|
+
* 而「点进自己组织架构页」不该要求管理员手填应用 id。别家企业没有这个兜底——
|
|
91
|
+
* 它们的 agent 只能来自登录链接/状态条回传,不由本站配置决定(那正是代开发的意义)。
|
|
92
|
+
*/
|
|
93
|
+
function selfAgentOf(tenantId: string): string | null {
|
|
94
|
+
const self = corpIdentity();
|
|
95
|
+
return self && self.corpId === tenantId ? self.agentId : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
/**
|
|
84
99
|
* 解析当前租户的授权档案(企业代码 = 租户;多应用时必须带 agent)。
|
|
85
100
|
*
|
|
@@ -88,14 +103,15 @@ function agentOf(raw: unknown): string | null {
|
|
|
88
103
|
* 是自己填错了),findProfile 负责解密出 permanent_code。
|
|
89
104
|
*/
|
|
90
105
|
async function profileOf(tenantId: string, agent: unknown) {
|
|
91
|
-
const probe = await probeProfile(tenantId, agentOf(agent));
|
|
106
|
+
const probe = await probeProfile(tenantId, agentOf(agent) ?? selfAgentOf(tenantId));
|
|
92
107
|
if (!probe) {
|
|
93
108
|
throw Object.assign(new Error("本站尚未开通企业微信:没有可同步的授权记录"), { status: 400 });
|
|
94
109
|
}
|
|
95
110
|
if (probe.status !== "active") {
|
|
96
111
|
throw Object.assign(new Error("该企业已取消授权或应用已停用:组织架构同步已冻结,请联系服务商重新开通"), { status: 400 });
|
|
97
112
|
}
|
|
98
|
-
|
|
113
|
+
// 同步只用与来源无关的两项(落库密钥 + 基址):读整套 suite 配置会把自建部署打成 400
|
|
114
|
+
const cfg = readBase();
|
|
99
115
|
const profile = await findProfile(tenantId, probe.agentId, runtimeStoreDeps(cfg));
|
|
100
116
|
return { profile, probe, cfg };
|
|
101
117
|
}
|
|
@@ -149,7 +165,7 @@ const status = {
|
|
|
149
165
|
const tenantId = currentTenant();
|
|
150
166
|
if (!tenantId)
|
|
151
167
|
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
152
|
-
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent));
|
|
168
|
+
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent) ?? selfAgentOf(tenantId));
|
|
153
169
|
if (!probe)
|
|
154
170
|
return attachProfile(emptyView("idle"), null);
|
|
155
171
|
return attachProfile(await syncStatus(tenantId, probe.agentId), probe);
|
|
@@ -19,6 +19,7 @@ export const schemas = {
|
|
|
19
19
|
"env": z.string(),
|
|
20
20
|
"corp_name": z.string().nullable().optional(),
|
|
21
21
|
"status": z.enum(["active", "disabled"]),
|
|
22
|
+
"credential_kind": z.enum(["suite", "corp"]),
|
|
22
23
|
"auth_mode": z.number(),
|
|
23
24
|
"installed_at": z.number(),
|
|
24
25
|
"update_time": z.number()
|
|
@@ -40,6 +41,24 @@ export const schemas = {
|
|
|
40
41
|
"installUrl": z.string(),
|
|
41
42
|
"callbackUrl": z.string(),
|
|
42
43
|
"expiresIn": z.number()
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
"authWecomAdminOverviewGet": {
|
|
47
|
+
data: z.object({
|
|
48
|
+
"kinds": z.array(z.enum(["suite", "corp"])),
|
|
49
|
+
"configError": z.string().nullable(),
|
|
50
|
+
"callbackUrl": z.string().nullable(),
|
|
51
|
+
"corpId": z.string().nullable(),
|
|
52
|
+
"agentId": z.string().nullable(),
|
|
53
|
+
"corpCallbackConfigured": z.boolean()
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
"authWecomAdminConnectPost": {
|
|
57
|
+
data: z.object({
|
|
58
|
+
"corpId": z.string(),
|
|
59
|
+
"agentId": z.string(),
|
|
60
|
+
"departments": z.number(),
|
|
61
|
+
"members": z.number()
|
|
43
62
|
}),
|
|
44
63
|
},
|
|
45
64
|
"authWecomTicketPost": {
|
|
@@ -108,7 +127,9 @@ export const schemas = {
|
|
|
108
127
|
}).nullable(),
|
|
109
128
|
"startedAt": z.number(),
|
|
110
129
|
"finishedAt": z.number(),
|
|
111
|
-
"updateTime": z.number()
|
|
130
|
+
"updateTime": z.number(),
|
|
131
|
+
"agentId": z.string().nullable(),
|
|
132
|
+
"credentialKind": z.union([z.literal("suite"), z.literal("corp"), z.literal(null)]).nullable()
|
|
112
133
|
}),
|
|
113
134
|
},
|
|
114
135
|
"authWecomSyncCancelPost": {
|
|
@@ -33,7 +33,7 @@ export function createAuthClient(ctx: ModuleContext) {
|
|
|
33
33
|
}
|
|
34
34
|
ctx.register.apiPrefix(API_PREFIX);
|
|
35
35
|
bindRequest(ctx.utils.request);
|
|
36
|
-
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomAdminOverviewGet, authWecomAdminConnectPost, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -211,6 +211,52 @@ export async function authWecomAdminInstallGet(): Promise<AuthWecomAdminInstallG
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
export type AuthWecomAdminOverviewGetData = z.infer<(typeof schemas)["authWecomAdminOverviewGet"]["data"]>;
|
|
215
|
+
|
|
216
|
+
export async function authWecomAdminOverviewGet(): Promise<AuthWecomAdminOverviewGetData> {
|
|
217
|
+
const client = ensureReq();
|
|
218
|
+
try {
|
|
219
|
+
const env = await client.get(`auth/wecom/admin/overview`).json<OjEnvelope<AuthWecomAdminOverviewGetData>>();
|
|
220
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
221
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
222
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
223
|
+
const data = env.data as AuthWecomAdminOverviewGetData;
|
|
224
|
+
if (import.meta.env.DEV) {
|
|
225
|
+
const { schemas } = await import("./api.schemas");
|
|
226
|
+
const r = schemas["authWecomAdminOverviewGet"].data.safeParse(data);
|
|
227
|
+
if (!r.success)
|
|
228
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminOverviewGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
229
|
+
}
|
|
230
|
+
return data;
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
throw await toApiError(e);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type AuthWecomAdminConnectPostData = z.infer<(typeof schemas)["authWecomAdminConnectPost"]["data"]>;
|
|
238
|
+
|
|
239
|
+
export async function authWecomAdminConnectPost(): Promise<AuthWecomAdminConnectPostData> {
|
|
240
|
+
const client = ensureReq();
|
|
241
|
+
try {
|
|
242
|
+
const env = await client.post(`auth/wecom/admin/connect`).json<OjEnvelope<AuthWecomAdminConnectPostData>>();
|
|
243
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
244
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
245
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
246
|
+
const data = env.data as AuthWecomAdminConnectPostData;
|
|
247
|
+
if (import.meta.env.DEV) {
|
|
248
|
+
const { schemas } = await import("./api.schemas");
|
|
249
|
+
const r = schemas["authWecomAdminConnectPost"].data.safeParse(data);
|
|
250
|
+
if (!r.success)
|
|
251
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminConnectPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
252
|
+
}
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
throw await toApiError(e);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
214
260
|
export async function authWecomLoginGet(): Promise<unknown> {
|
|
215
261
|
const client = ensureReq();
|
|
216
262
|
try {
|
|
@@ -141,7 +141,8 @@
|
|
|
141
141
|
"status": "Status",
|
|
142
142
|
"installedAt": "Onboarded at",
|
|
143
143
|
"action": "Actions",
|
|
144
|
-
"authMode": "Auth mode"
|
|
144
|
+
"authMode": "Auth mode",
|
|
145
|
+
"source": "Source"
|
|
145
146
|
},
|
|
146
147
|
"statusActive": "Active",
|
|
147
148
|
"statusDisabled": "Disabled",
|
|
@@ -205,6 +206,28 @@
|
|
|
205
206
|
"hint": "Contact the service provider to investigate, then retry."
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
},
|
|
210
|
+
"source": {
|
|
211
|
+
"suite": "Delegated",
|
|
212
|
+
"corp": "Internal app"
|
|
213
|
+
},
|
|
214
|
+
"authModeNone": "—",
|
|
215
|
+
"configErrorTitle": "Deployment config check failed",
|
|
216
|
+
"coexistHint": "This deployment runs both delegated (suite) and internal (corp) apps (wecom_allow_coexist: 'true').",
|
|
217
|
+
"corp": {
|
|
218
|
+
"title": "Internal app (self-built)",
|
|
219
|
+
"hint": "This deployment connects as an internal app: credentials come from config.yaml (wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id). Fill them in, then click \"Verify and connect\"; the corporation is registered only after all three checks pass.",
|
|
220
|
+
"corpId": "Corporation ID",
|
|
221
|
+
"agentId": "AgentId",
|
|
222
|
+
"callbackUrl": "Contacts event callback URL",
|
|
223
|
+
"callbackHint": "Fill this URL into WeCom → Tools → Contacts sync → Set event receiver URL. Leave the app's own message callback empty: the two use different tokens, and pointing both at one URL makes half the payloads fail signature verification.",
|
|
224
|
+
"callbackConfigured": "Contacts event callback credentials",
|
|
225
|
+
"callbackYes": "Configured (member changes sync in real time)",
|
|
226
|
+
"callbackNo": "Not configured (full sync only; to get increments, fill Token / EncodingAESKey for this URL in the WeCom console)",
|
|
227
|
+
"connect": "Verify and connect",
|
|
228
|
+
"connectConfirm": "Connect checks: whether the app Secret can fetch a token, whether contacts are readable, and whether any member is visible. Re-run it whenever the Corporation ID / AgentId changed or the Secret was rotated.",
|
|
229
|
+
"connectOk": "Connected: {{depts}} departments visible, {{members}} members in the sample department",
|
|
230
|
+
"connectHint": "After rotating the app Secret you must click \"Verify and connect\" again, otherwise every call fails with 40001. How many people get synced depends on the app's visible scope — set it to the whole company for a full sync."
|
|
208
231
|
}
|
|
209
232
|
},
|
|
210
233
|
"err": {
|
|
@@ -141,7 +141,8 @@
|
|
|
141
141
|
"status": "状态",
|
|
142
142
|
"installedAt": "开通时间",
|
|
143
143
|
"action": "操作",
|
|
144
|
-
"authMode": "授权模式"
|
|
144
|
+
"authMode": "授权模式",
|
|
145
|
+
"source": "来源"
|
|
145
146
|
},
|
|
146
147
|
"statusActive": "启用",
|
|
147
148
|
"statusDisabled": "停用",
|
|
@@ -205,6 +206,28 @@
|
|
|
205
206
|
"hint": "请联系服务商排查后重试。"
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
},
|
|
210
|
+
"source": {
|
|
211
|
+
"suite": "代开发",
|
|
212
|
+
"corp": "企业内部应用"
|
|
213
|
+
},
|
|
214
|
+
"authModeNone": "—",
|
|
215
|
+
"configErrorTitle": "部署配置自检未通过",
|
|
216
|
+
"coexistHint": "本部署同时启用了代开发与企业内部应用(wecom_allow_coexist: 'true')。",
|
|
217
|
+
"corp": {
|
|
218
|
+
"title": "企业内部应用(自建)",
|
|
219
|
+
"hint": "本部署作为企业内部应用接入:凭据来自 config.yaml 的 wecom_corp_id / wecom_corp_secret / wecom_corp_agent_id。填好后点「验证并接入」,三步校验全过才会登记企业。",
|
|
220
|
+
"corpId": "企业ID",
|
|
221
|
+
"agentId": "AgentId",
|
|
222
|
+
"callbackUrl": "通讯录事件回调地址",
|
|
223
|
+
"callbackHint": "把上面这个地址填到企微「管理工具 → 通讯录同步 → 设置接收事件服务器」;应用自身的「接收消息」回调请留空——两处 Token 不同,填同一个 URL 会让一半报文验签失败。",
|
|
224
|
+
"callbackConfigured": "通讯录事件回调凭据",
|
|
225
|
+
"callbackYes": "已配置(成员变更会实时同步)",
|
|
226
|
+
"callbackNo": "未配置(只做全量同步;要增量请在企微后台填 Token / EncodingAESKey 到同一地址)",
|
|
227
|
+
"connect": "验证并接入",
|
|
228
|
+
"connectConfirm": "接入会依次校验:应用 Secret 能否换 token、能否读通讯录、可见范围内能否读到人。改过企业ID / AgentId 或换过 Secret 都必须重新点一次。",
|
|
229
|
+
"connectOk": "接入成功:可见范围内 {{depts}} 个部门,示例部门 {{members}} 人",
|
|
230
|
+
"connectHint": "换过应用 Secret 后必须重新「验证并接入」,否则所有调用都会 40001;同步到多少人取决于「应用可见范围」,要全量请设为全公司。"
|
|
208
231
|
}
|
|
209
232
|
},
|
|
210
233
|
"err": {
|