@oj-module/cli 0.2.46 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/SKILL.md +1 -1
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +24 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +282 -27
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/config.yaml +26 -7
- 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 +17 -10
- package/templates/src/auth/api/_shared/wecom/client.ts +311 -40
- package/templates/src/auth/api/_shared/wecom/config.ts +40 -17
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +152 -1
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +487 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +7 -5
- package/templates/src/auth/api/wecom/api.ts +100 -18
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/login/web/auth-provider.ts +9 -1
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +113 -7
- package/templates/src/system/web/locales/zh-CN.json +113 -7
- 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 +16 -1
- 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 +64 -42
- package/templates/tests/wecom-client-timeout.spec.ts +99 -0
- package/templates/tests/wecom-connector.spec.ts +115 -11
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +182 -14
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -33,11 +33,36 @@ export interface WecomClientDeps {
|
|
|
33
33
|
cache: WecomCache
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 软超时:服务端 isolate 无 `setTimeout` / `AbortSignal` 全局,不能用 `AbortSignal.timeout`。
|
|
38
|
+
* 这里用运行时注入的 `tasks.sleep(ms)` 起一个「到点就 reject」的竞态;fetch 不被真正 abort,
|
|
39
|
+
* 只是不再等它(后台连接最终由 OS/池回收)。`tasks` 不可用时降级为原 promise(无超时)。
|
|
40
|
+
* 输家 promise 在 race 结束后仍可能 reject,挂 `.catch` 防 unhandled rejection。
|
|
41
|
+
*/
|
|
42
|
+
function withSoftTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
43
|
+
const g = globalThis as unknown as { tasks?: { sleep?: (ms: number) => Promise<void> } };
|
|
44
|
+
const sleep = g.tasks?.sleep;
|
|
45
|
+
if (typeof sleep !== "function")
|
|
46
|
+
return p;
|
|
47
|
+
const timeout = sleep(ms).then(
|
|
48
|
+
() => { throw new Error(`超时 ${ms}ms`); },
|
|
49
|
+
() => undefined as never, // sleep 自身异常不中断主链路(返回 never 以免 race 结果退化为 void | T)
|
|
50
|
+
);
|
|
51
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
52
|
+
// 输家 promise 在 race 结束后仍可能 reject,挂 catch 防 unhandled rejection
|
|
53
|
+
timeout.catch(() => {});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
export function runtimeHttp(): WecomHttp {
|
|
37
58
|
async function call(url: string, init: RequestInit): Promise<Record<string, unknown>> {
|
|
38
59
|
let res: Response;
|
|
39
60
|
try {
|
|
40
|
-
|
|
61
|
+
// 服务端 isolate 无 `AbortSignal` / `setTimeout` 全局(见 oj-api-dev global.d.ts:
|
|
62
|
+
// 「本运行时无 timer 全局」),`AbortSignal.timeout()` 直接 ReferenceError。
|
|
63
|
+
// 用运行时提供的 `tasks.sleep` 做软超时(到点 reject;fetch 仍在后台跑、不 abort),
|
|
64
|
+
// 拿不到 tasks 时降级为「不超时」——网络层失败仍会被下面 catch 转成 502。
|
|
65
|
+
res = await withSoftTimeout(fetch(url, init), TIMEOUT_MS);
|
|
41
66
|
}
|
|
42
67
|
catch (e) {
|
|
43
68
|
throw upstream(`企微接口不可达:${(e as Error).message}`);
|
|
@@ -93,6 +118,88 @@ function assertOk(res: Record<string, unknown>, what: string): void {
|
|
|
93
118
|
|
|
94
119
|
const TOKEN_EXPIRED = new Set([40001, 40014, 42001]);
|
|
95
120
|
|
|
121
|
+
/**
|
|
122
|
+
* 错码三分类(方案 §5.1)。同步器按 kind 决定处置,**不再一律当 502**:
|
|
123
|
+
* · permission:该部门不在应用权限/可见范围内 → 跳过并计数,本轮不判失败(否则一个部门无权限就整轮 failed);
|
|
124
|
+
* · ratelimit:指数退避重试(企微每企业 ≤1 万次/分、15 万次/小时,正常量级撞不到,撞到就是并发高了);
|
|
125
|
+
* · fatal:60020(出口 IP 未白名单)/ 46004(成员已删)/ 网络等——重试无用,计入熔断。
|
|
126
|
+
*/
|
|
127
|
+
export type WecomErrorKind = "permission" | "ratelimit" | "fatal";
|
|
128
|
+
|
|
129
|
+
const PERMISSION_CODES = new Set([48002, 60011]);
|
|
130
|
+
const RATELIMIT_CODES = new Set([45009, 45033]);
|
|
131
|
+
|
|
132
|
+
export class WecomApiError extends Error {
|
|
133
|
+
readonly kind: WecomErrorKind;
|
|
134
|
+
readonly errcode: number;
|
|
135
|
+
constructor(what: string, errcode: number, errmsg: string) {
|
|
136
|
+
super(`企微 ${what} 失败:errcode=${errcode} ${errmsg}`.trimEnd());
|
|
137
|
+
this.name = "WecomApiError";
|
|
138
|
+
this.errcode = errcode;
|
|
139
|
+
this.kind = PERMISSION_CODES.has(errcode) ? "permission" : RATELIMIT_CODES.has(errcode) ? "ratelimit" : "fatal";
|
|
140
|
+
// 502:对上层仍是「上游出错」,路由信封口径不变;kind 只给同步器分流用
|
|
141
|
+
Object.assign(this, { status: 502 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 退避用的软 sleep:isolate 无 timer 全局,拿不到 tasks 就退化成「不等待直接重试」 */
|
|
146
|
+
function softSleep(ms: number): Promise<void> {
|
|
147
|
+
const g = globalThis as unknown as { tasks?: { sleep?: (ms: number) => Promise<void> } };
|
|
148
|
+
const sleep = g.tasks?.sleep;
|
|
149
|
+
if (typeof sleep !== "function")
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
return sleep(ms).catch(() => undefined);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 通讯录部门(simplelist/list 的公共形态):企微部门 id 是数字,这里统一按字符串处理(§4.1 origin_id) */
|
|
155
|
+
export interface WecomDept {
|
|
156
|
+
id: string
|
|
157
|
+
parentId: string
|
|
158
|
+
name: string | null
|
|
159
|
+
nameEn: string | null
|
|
160
|
+
order: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 通讯录成员(user/list 的形态):`department` / `order` / `is_leader_in_dept` 逐位对应 */
|
|
164
|
+
export interface WecomMember {
|
|
165
|
+
userid: string
|
|
166
|
+
name: string | null
|
|
167
|
+
departments: string[]
|
|
168
|
+
orders: number[]
|
|
169
|
+
leaderInDept: number[]
|
|
170
|
+
mainDepartment: number | null
|
|
171
|
+
position: string | null
|
|
172
|
+
/** 1=已激活 2=已禁用 4=未激活 5=退出企业(§4.3,只存快照不改 is_forbidden) */
|
|
173
|
+
status: number | null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 授权信息里的应用列表(`auth_info.agent`)——`get_permanent_code` 与 `get_auth_info` 同形,
|
|
178
|
+
* 抽出来是为了两处对 auth_mode 的读法永远一致(同步闸门认的就是这个值)。
|
|
179
|
+
*/
|
|
180
|
+
function agentListOf(authInfo: unknown): Array<Record<string, unknown>> {
|
|
181
|
+
const list = (authInfo as Record<string, unknown> | undefined)?.agent;
|
|
182
|
+
return Array.isArray(list) ? list.map(a => a as Record<string, unknown>) : [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* agent_id → auth_mode。auth_mode 在 agent 项上(每个应用各自一条);老回包里偶尔只在
|
|
187
|
+
* auth_info 顶层给,故两级都读,缺省按 0(管理员授权)——成员授权会导致同步 100% 60011,
|
|
188
|
+
* 不能靠猜成 1。
|
|
189
|
+
*/
|
|
190
|
+
function readAuthModes(authInfo: unknown): Record<string, number> {
|
|
191
|
+
const top = (authInfo as Record<string, unknown> | undefined)?.auth_mode;
|
|
192
|
+
const modes: Record<string, number> = {};
|
|
193
|
+
for (const a of agentListOf(authInfo)) {
|
|
194
|
+
const agentId = String(a.agentid ?? "");
|
|
195
|
+
if (!agentId)
|
|
196
|
+
continue;
|
|
197
|
+
const mode = Number(a.auth_mode ?? top ?? 0);
|
|
198
|
+
modes[agentId] = Number.isFinite(mode) ? mode : 0;
|
|
199
|
+
}
|
|
200
|
+
return modes;
|
|
201
|
+
}
|
|
202
|
+
|
|
96
203
|
export function createWecomClient(deps: WecomClientDeps) {
|
|
97
204
|
const { http, cache } = deps;
|
|
98
205
|
|
|
@@ -131,15 +238,15 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
131
238
|
corpId: string
|
|
132
239
|
corpName: string | null
|
|
133
240
|
agentIds: string[]
|
|
241
|
+
/** agent_id → auth_mode(0=管理员授权 / 1=成员授权);成员授权下应用无任何部门权限(§2.3) */
|
|
242
|
+
authModes: Record<string, number>
|
|
134
243
|
state: string | null
|
|
135
244
|
}> {
|
|
136
245
|
const res = await http.postJson(`${API_BASE}/cgi-bin/service/v2/get_permanent_code?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, { auth_code: authCode });
|
|
137
246
|
assertOk(res, "get_permanent_code");
|
|
138
247
|
const authCorp = (res.auth_corp_info ?? {}) as Record<string, unknown>;
|
|
139
|
-
const
|
|
140
|
-
const agentIds =
|
|
141
|
-
? agentList.map(a => String((a as Record<string, unknown>).agentid)).filter(Boolean)
|
|
142
|
-
: [];
|
|
248
|
+
const agents = agentListOf(res.auth_info);
|
|
249
|
+
const agentIds = agents.map(a => String(a.agentid)).filter(Boolean);
|
|
143
250
|
if (!res.permanent_code || !authCorp.corpid)
|
|
144
251
|
throw upstream("企微 get_permanent_code 返回缺少 permanent_code / corpid");
|
|
145
252
|
if (agentIds.length === 0)
|
|
@@ -149,20 +256,70 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
149
256
|
corpId: String(authCorp.corpid),
|
|
150
257
|
corpName: authCorp.corp_name == null ? null : String(authCorp.corp_name),
|
|
151
258
|
agentIds,
|
|
259
|
+
authModes: readAuthModes(res.auth_info),
|
|
152
260
|
state: res.state == null ? null : String(res.state),
|
|
153
261
|
};
|
|
154
262
|
}
|
|
155
263
|
|
|
156
264
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
265
|
+
* 复核授权模式(`get_auth_info`):`change_auth` 事件只带 AuthCorpId、**不带 AuthCode**,
|
|
266
|
+
* 所以刷新 auth_mode 只能拿已存的 permanent_code 反查。企业管理员在后台把「管理员授权」
|
|
267
|
+
* 切成「成员授权」(或反向)就靠这条路径同步——切过去以后部门接口一律 60011,
|
|
268
|
+
* 不刷新就会一直按「可同步」对待。
|
|
159
269
|
*/
|
|
160
|
-
async function
|
|
161
|
-
const res = await http.postJson(`${API_BASE}/cgi-bin/service/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
270
|
+
async function authModesByPermanentCode(suiteAccessToken: string, corpId: string, permanentCode: string): Promise<Record<string, number>> {
|
|
271
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_auth_info?suite_access_token=${encodeURIComponent(suiteAccessToken)}`, {
|
|
272
|
+
auth_corpid: corpId,
|
|
273
|
+
permanent_code: permanentCode,
|
|
274
|
+
});
|
|
275
|
+
assertOk(res, "get_auth_info");
|
|
276
|
+
return readAuthModes(res.auth_info);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 服务商级 token(provider_access_token):代开发「生成授权链接」用,与套件 token 不同源。
|
|
281
|
+
* 由服务商 corpid + provider_secret 换得,全部署共享一份(不限 suite)。
|
|
282
|
+
* 注意:这是**服务商**凭证,不是 suite 凭证——代开发的 `get_customized_auth_url`
|
|
283
|
+
* 只认 provider_access_token(用 suite_access_token 调会报 token 无效类错误)。
|
|
284
|
+
*/
|
|
285
|
+
async function providerAccessToken(providerId: string, providerSecret: string): Promise<string> {
|
|
286
|
+
return cachedToken("wecom:provider_token", async () => {
|
|
287
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_provider_token`, {
|
|
288
|
+
corpid: providerId,
|
|
289
|
+
provider_secret: providerSecret,
|
|
290
|
+
});
|
|
291
|
+
assertOk(res, "get_provider_token");
|
|
292
|
+
if (!res.provider_access_token)
|
|
293
|
+
throw upstream("企微 get_provider_token 未返回 provider_access_token");
|
|
294
|
+
return { token: String(res.provider_access_token), expiresIn: Number(res.expires_in ?? 7200) };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 代开发授权链接(get_customized_auth_url):返回 `qrcode_url`(即企业管理员要扫/开的开通链接,
|
|
300
|
+
* 运营侧常称 auth_url)。**代开发明确不支持**第三方应用那个 `get_pre_auth_code`(errcode 48002),
|
|
301
|
+
* 必须用本接口。链接有效期以企微返回为准(可达 10 天),state 透传给授权成功回调原样回传。
|
|
302
|
+
* `templateid_list` 填代开发模板 ID(本系统即 suite_id,代开发模板与 suite 同标识)。
|
|
303
|
+
*/
|
|
304
|
+
async function customizedAuthUrl(providerAccessToken: string, templateIds: string[], state: string): Promise<{ url: string, expiresIn: number }> {
|
|
305
|
+
// state 必填(errcode 40058 state missing),限 ≤32 字节;调用方没传(前端目前不传)就给默认常量。
|
|
306
|
+
// 注:本系统开通链接不靠 state 携带 env(env 走登录链接),故空时给固定默认值即可。
|
|
307
|
+
const st = state ? state.slice(0, 32) : "ojmdaikaifa";
|
|
308
|
+
const res = await http.postJson(`${API_BASE}/cgi-bin/service/get_customized_auth_url?provider_access_token=${encodeURIComponent(providerAccessToken)}`, {
|
|
309
|
+
state: st,
|
|
310
|
+
templateid_list: templateIds,
|
|
311
|
+
});
|
|
312
|
+
// 842002 = 代开发模板未上架/上线:模板创建成功 ≠ 上线,必须到服务商后台
|
|
313
|
+
// 「应用管理 → 代开发应用上线」提交审核、审核通过后再手动「提交上线」,
|
|
314
|
+
// 模板正式上线后 get_customized_auth_url 才会生成 qrcode_url。这是后台运营前置动作,
|
|
315
|
+
// 与代码/参数/凭证无关(否则会先报 40058/60020/40001 之类)。单独挑出给可操作提示。
|
|
316
|
+
if (Number(res.errcode ?? 0) === 842002)
|
|
317
|
+
throw upstream("企微 get_customized_auth_url 失败:errcode=842002 代开发模板尚未上架/上线——请到服务商后台「应用管理 → 代开发应用上线」提交模板上线审核,审核通过后手动点「提交上线」,模板正式上线后再生成开通链接");
|
|
318
|
+
assertOk(res, "get_customized_auth_url");
|
|
319
|
+
const url = res.qrcode_url ?? res.auth_url;
|
|
320
|
+
if (!url)
|
|
321
|
+
throw upstream("企微 get_customized_auth_url 未返回 qrcode_url");
|
|
322
|
+
return { url: String(url), expiresIn: Number(res.expires_in ?? 7200) };
|
|
166
323
|
}
|
|
167
324
|
|
|
168
325
|
/** 企业 access_token(代开发 = 自建应用的 gettoken,corpsecret 就是 permanent_code) */
|
|
@@ -180,6 +337,127 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
180
337
|
await cache.del(`wecom:corp_token:${corpId}:${agentId}`);
|
|
181
338
|
}
|
|
182
339
|
|
|
340
|
+
/**
|
|
341
|
+
* 通讯录类 GET 的公共通道:token 失效重取 + 三类错码分流。
|
|
342
|
+
*
|
|
343
|
+
* 重试上限是有意的:token 失效最多换两次(企微侧提前失效是常态),限流退避最多三次
|
|
344
|
+
* (500ms/1s/2s),其余错码立即抛——同步器靠 kind 决定「跳过并计数」还是「计入熔断」,
|
|
345
|
+
* 所以**必须把 errcode 带出去**,不能像 assertOk 那样拍平成一句中文。
|
|
346
|
+
*/
|
|
347
|
+
async function corpGet(
|
|
348
|
+
corpId: string,
|
|
349
|
+
agentId: string,
|
|
350
|
+
secret: string,
|
|
351
|
+
path: string,
|
|
352
|
+
params: Record<string, string | number | undefined>,
|
|
353
|
+
what: string,
|
|
354
|
+
): Promise<Record<string, unknown>> {
|
|
355
|
+
const qs = Object.entries(params)
|
|
356
|
+
.filter(([, v]) => v != null && v !== "")
|
|
357
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
|
358
|
+
.join("&");
|
|
359
|
+
for (let attempt = 0; ; attempt++) {
|
|
360
|
+
const token = await corpAccessToken(corpId, agentId, secret);
|
|
361
|
+
const res = await http.getJson(`${API_BASE}${path}?access_token=${encodeURIComponent(token)}${qs ? `&${qs}` : ""}`);
|
|
362
|
+
const errcode = Number(res.errcode ?? 0);
|
|
363
|
+
if (!errcode)
|
|
364
|
+
return res;
|
|
365
|
+
if (TOKEN_EXPIRED.has(errcode) && attempt < 2) {
|
|
366
|
+
await invalidateCorpToken(corpId, agentId);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const err = new WecomApiError(what, errcode, String(res.errmsg ?? ""));
|
|
370
|
+
if (err.kind === "ratelimit" && attempt < 2) {
|
|
371
|
+
await softSleep(500 * 2 ** attempt);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toDept(raw: Record<string, unknown>): WecomDept {
|
|
379
|
+
return {
|
|
380
|
+
id: String(raw.id ?? ""),
|
|
381
|
+
parentId: raw.parentid == null ? "" : String(raw.parentid),
|
|
382
|
+
name: raw.name == null ? null : String(raw.name),
|
|
383
|
+
nameEn: raw.name_en == null ? null : String(raw.name_en),
|
|
384
|
+
order: Number(raw.order ?? 0),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 子部门 id 列表(接口 1):全量同步的**主路径**——官方 90208 明写 `department/list` 性能低。
|
|
390
|
+
* `parentOriginId` 缺省时企微返回「应用可见范围内所有部门」,这既是 BFS 的起点,也是兜底路径。
|
|
391
|
+
*/
|
|
392
|
+
async function departmentChildren(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
|
|
393
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/simplelist", { id: parentOriginId }, "department/simplelist");
|
|
394
|
+
const list = Array.isArray(res.department_id) ? res.department_id as Array<Record<string, unknown>> : [];
|
|
395
|
+
return list.filter(d => d && d.id != null).map(toDept);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 单部门详情(接口 2):simplelist 不带名字,名字只能从这里取(未授权时 name 为空) */
|
|
399
|
+
async function departmentDetail(corpId: string, agentId: string, secret: string, originId: string): Promise<WecomDept> {
|
|
400
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/get", { id: originId }, "department/get");
|
|
401
|
+
const d = res.department as Record<string, unknown> | undefined;
|
|
402
|
+
if (!d || d.id == null)
|
|
403
|
+
throw upstream("企微 department/get 未返回部门详情");
|
|
404
|
+
return toDept(d);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** 一次拿全量部门(接口 3):降级兜底,1/2 因权限拿不到时用 */
|
|
408
|
+
async function departmentList(corpId: string, agentId: string, secret: string, parentOriginId?: string): Promise<WecomDept[]> {
|
|
409
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/department/list", { id: parentOriginId }, "department/list");
|
|
410
|
+
const list = Array.isArray(res.department) ? res.department as Array<Record<string, unknown>> : [];
|
|
411
|
+
return list.filter(d => d && d.id != null).map(toDept);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function toMember(raw: Record<string, unknown>, detail: boolean): WecomMember {
|
|
415
|
+
const departments = Array.isArray(raw.department) ? raw.department.map(d => String(d)) : [];
|
|
416
|
+
const orders = detail && Array.isArray(raw.order) ? raw.order.map(o => Number(o)) : [];
|
|
417
|
+
const leader = detail && Array.isArray(raw.is_leader_in_dept) ? raw.is_leader_in_dept.map(o => Number(o)) : [];
|
|
418
|
+
return {
|
|
419
|
+
userid: String(raw.userid ?? ""),
|
|
420
|
+
name: raw.name == null ? null : String(raw.name),
|
|
421
|
+
departments,
|
|
422
|
+
orders,
|
|
423
|
+
leaderInDept: leader,
|
|
424
|
+
mainDepartment: detail && raw.main_department != null ? Number(raw.main_department) : null,
|
|
425
|
+
position: detail && raw.position != null ? String(raw.position) : null,
|
|
426
|
+
status: detail && raw.status != null ? Number(raw.status) : null,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 部门成员(接口 4,轻量):`user/simplelist` 无 fetch_child,子部门要调用方自己递归 */
|
|
431
|
+
async function deptMembers(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
|
|
432
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/simplelist", { department_id: deptOriginId }, "user/simplelist");
|
|
433
|
+
const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
|
|
434
|
+
return list.filter(u => u && u.userid != null).map(u => toMember(u, false));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 部门成员详情(接口 5,重量):职务 / 排序 / 主部门 / 在职状态只有这里有;同一部门只该调一次 */
|
|
438
|
+
async function deptMembersDetail(corpId: string, agentId: string, secret: string, deptOriginId: string): Promise<WecomMember[]> {
|
|
439
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/list", { department_id: deptOriginId }, "user/list");
|
|
440
|
+
const list = Array.isArray(res.userlist) ? res.userlist as Array<Record<string, unknown>> : [];
|
|
441
|
+
return list.filter(u => u && u.userid != null).map(u => toMember(u, true));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 单成员详情(接口 6):**登录自更新**用它,四次/次调用就够。
|
|
446
|
+
*
|
|
447
|
+
* best-effort:任何失败(无权限 / 不在可见范围 / 上游抖)返回 null,绝不抛出——
|
|
448
|
+
* 补资料是体验优化,不能因为拿不到把登录打成 5xx。调用方拿到 null 就保留兜底值。
|
|
449
|
+
*/
|
|
450
|
+
async function userDetail(corpId: string, agentId: string, secret: string, userid: string): Promise<WecomMember | null> {
|
|
451
|
+
try {
|
|
452
|
+
const res = await corpGet(corpId, agentId, secret, "/cgi-bin/user/get", { userid }, "user/get");
|
|
453
|
+
// 回包偶尔不带 userid(老接口/精简回包):入参就是权威,用入参补
|
|
454
|
+
return toMember({ ...res, userid: res.userid ?? userid }, true);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
183
461
|
/**
|
|
184
462
|
* 成员姓名(best-effort):只用于把 JIT 号的 `display_name` 从工号变成真名。
|
|
185
463
|
*
|
|
@@ -189,32 +467,9 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
189
467
|
* token 复用 `userIdByCode` 刚取的那份缓存(同请求内通常 0 次额外换票)。
|
|
190
468
|
*/
|
|
191
469
|
async function userNameById(corpId: string, agentId: string, secret: string, userid: string): Promise<string | null> {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
470
|
+
const detail = await userDetail(corpId, agentId, secret, userid);
|
|
471
|
+
const name = detail?.name?.trim();
|
|
472
|
+
return name || null;
|
|
218
473
|
}
|
|
219
474
|
|
|
220
475
|
/** 网页授权 code → userid(snsapi_base;非企业成员/超出可见范围 → 401) */
|
|
@@ -237,7 +492,23 @@ export function createWecomClient(deps: WecomClientDeps) {
|
|
|
237
492
|
throw upstream("企微 token 反复失效(40001/42001)");
|
|
238
493
|
}
|
|
239
494
|
|
|
240
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
suiteAccessToken,
|
|
497
|
+
permanentCodeByAuthCode,
|
|
498
|
+
authModesByPermanentCode,
|
|
499
|
+
providerAccessToken,
|
|
500
|
+
customizedAuthUrl,
|
|
501
|
+
corpAccessToken,
|
|
502
|
+
invalidateCorpToken,
|
|
503
|
+
userIdByCode,
|
|
504
|
+
userNameById,
|
|
505
|
+
userDetail,
|
|
506
|
+
departmentChildren,
|
|
507
|
+
departmentDetail,
|
|
508
|
+
departmentList,
|
|
509
|
+
deptMembers,
|
|
510
|
+
deptMembersDetail,
|
|
511
|
+
};
|
|
241
512
|
}
|
|
242
513
|
|
|
243
514
|
export type WecomClient = ReturnType<typeof createWecomClient>;
|
|
@@ -41,6 +41,14 @@ export interface WecomConfig extends WecomCallbackConfig {
|
|
|
41
41
|
/** 代开发应用模版 id(企微侧即 suite_id),也是回调明文里的 receiveid */
|
|
42
42
|
suiteId: string
|
|
43
43
|
suiteSecret: string
|
|
44
|
+
/**
|
|
45
|
+
* 服务商 corpid(不是企业 corpid,是「服务商」自己的 corpid)。
|
|
46
|
+
* 代开发「生成授权链接」走 `get_customized_auth_url`,该接口只认 provider_access_token,
|
|
47
|
+
* 而 provider_access_token 由服务商 corpid + provider_secret 换得——与 suite 凭证不同源。
|
|
48
|
+
*/
|
|
49
|
+
providerId: string
|
|
50
|
+
/** 服务商 provider_secret(在服务商后台「通用开发参数」页拿,别和 suite_secret 搞混) */
|
|
51
|
+
providerSecret: string
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
// Tier A:部署方本地生成,创建模版前即可配齐(token / EncodingAESKey / 落库密钥 / 基址)。
|
|
@@ -57,6 +65,14 @@ const REQUIRED_SUITE: Array<[keyof WecomConfig, string]> = [
|
|
|
57
65
|
["suiteSecret", "wecom_suite_secret"],
|
|
58
66
|
];
|
|
59
67
|
|
|
68
|
+
// Tier C:服务商凭证(provider_access_token 源)。**仅「生成授权链接」需要**,不影响
|
|
69
|
+
// login/callback/create_auth 等 suite 流程,故不进 readWecomConfig 的必填集合(readWecomConfig
|
|
70
|
+
// 被登录链路复用,缺 provider 凭证不该让登录 400)。createInstall 单独校验。
|
|
71
|
+
const REQUIRED_PROVIDER: Array<[keyof WecomConfig, string]> = [
|
|
72
|
+
["providerId", "wecom_provider_id"],
|
|
73
|
+
["providerSecret", "wecom_provider_secret"],
|
|
74
|
+
];
|
|
75
|
+
|
|
60
76
|
/** 一次性读出六处部署常量(未 trim 的尾斜杠在此归一) */
|
|
61
77
|
function readWecomVars(): WecomConfig {
|
|
62
78
|
return {
|
|
@@ -66,6 +82,8 @@ function readWecomVars(): WecomConfig {
|
|
|
66
82
|
baseUrl: (vars.get("wecom_base_url") ?? "").replace(/\/+$/, ""),
|
|
67
83
|
suiteId: vars.get("wecom_suite_id") ?? "",
|
|
68
84
|
suiteSecret: vars.get("wecom_suite_secret") ?? "",
|
|
85
|
+
providerId: vars.get("wecom_provider_id") ?? "",
|
|
86
|
+
providerSecret: vars.get("wecom_provider_secret") ?? "",
|
|
69
87
|
};
|
|
70
88
|
}
|
|
71
89
|
|
|
@@ -90,6 +108,16 @@ export function assertEncKey(encKey: string): void {
|
|
|
90
108
|
throw Object.assign(new Error("企业微信配置错误:wecom_enc_key 必须是 16 或 32 字节的 hex(32/64 字符)或 base64"), { status: 400 });
|
|
91
109
|
}
|
|
92
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 校验服务商凭证(生成授权链接必需)。缺任一即 400 并点名缺哪两项——
|
|
113
|
+
* 区分于 suite 凭证:那是「模版创建后下发」,provider 凭证是「服务商后台通用开发参数」里拿的。
|
|
114
|
+
*/
|
|
115
|
+
export function assertProviderConfig(cfg: WecomConfig): void {
|
|
116
|
+
const missing = REQUIRED_PROVIDER.filter(([field]) => !cfg[field]).map(([, name]) => name);
|
|
117
|
+
if (missing.length)
|
|
118
|
+
throw Object.assign(new Error(`企业微信未配置:生成授权链接需要 ${missing.join(" / ")}(服务商后台「通用开发参数」页获取,与 suite 凭证不同源)`), { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
/**
|
|
94
122
|
* 读 Tier A 凭据(token / aeskey / encKey / baseUrl)。**不依赖 suite 凭据**:这是 echostr
|
|
95
123
|
* URL 验证与永久码落库加密所需的全部东西,部署方本地即可生成。引导期(suite_id 尚未下发)
|
|
@@ -125,7 +153,18 @@ export function readWecomConfig(): WecomConfig {
|
|
|
125
153
|
return v;
|
|
126
154
|
}
|
|
127
155
|
|
|
128
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* 企微后台「指令回调 URL / 应用回调 URL」:原样填这个(base + `/event`)。
|
|
158
|
+
*
|
|
159
|
+
* **不带 `?callback_format=json`**——查过官方文档才这么定的:该参数只在「对外收款」的指令回调
|
|
160
|
+
* 文档(97478)里有说明,而代开发 / 第三方应用所属的**通用回调配置**(96238 §3.2)至今只写
|
|
161
|
+
* XML 外层信封(`<ToUserName><AgentID><Encrypt>`),全文没有这个参数;「部门通知事件」(92655)
|
|
162
|
+
* 虽给了 JSON 示例,但示例地址同样不带它,也没说怎么切过去。
|
|
163
|
+
*
|
|
164
|
+
* 也就是说:给我们这类回调加上它,是**文档未覆盖**的行为。省下的只是服务端一次正则,
|
|
165
|
+
* 代价却是把「企业管理员扫码能否装成功」押在上游实现上。服务端两种包体都认(payload.ts),
|
|
166
|
+
* 上游哪天默认改 JSON 也不用动这里。
|
|
167
|
+
*/
|
|
129
168
|
export function eventUrlOf(cfg: WecomConfig): string {
|
|
130
169
|
return `${cfg.baseUrl}/event`;
|
|
131
170
|
}
|
|
@@ -153,22 +192,6 @@ export function siteRootOf(cfg: WecomConfig): string {
|
|
|
153
192
|
return `${url.origin}${path.slice(0, -CONNECTOR_SUFFIX.length)}`;
|
|
154
193
|
}
|
|
155
194
|
|
|
156
|
-
/**
|
|
157
|
-
* 代开发「开通入口」:企业管理员点开 → 企微授权安装页。
|
|
158
|
-
* `redirect_uri` 落在站点根下的登录页(安装完回站点,指令回调已把 profile 落好)。
|
|
159
|
-
*/
|
|
160
|
-
export function installUrlOf(cfg: WecomConfig, preAuthCode: string, state: string): string {
|
|
161
|
-
const redirect = `${siteRootOf(cfg)}/login?wecom_installed=1`;
|
|
162
|
-
const q = new URLSearchParams({
|
|
163
|
-
suite_id: cfg.suiteId,
|
|
164
|
-
pre_auth_code: preAuthCode,
|
|
165
|
-
redirect_uri: redirect,
|
|
166
|
-
});
|
|
167
|
-
if (state)
|
|
168
|
-
q.set("state", state);
|
|
169
|
-
return `https://open.work.weixin.qq.com/3rdapp/install?${q.toString()}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
195
|
/** 登录方式发现用:只看开关,不读凭据(登录页要先知道「有没有这个方式」) */
|
|
173
196
|
export async function wecomEnabled(): Promise<boolean> {
|
|
174
197
|
const rows = await db.table("sys_setting").select(["value"]).where({ field: "key", op: "eq", value: "wecom.enabled" }).all() as Array<{ value: string | null }>;
|