@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WecomCache, WecomClient } from "./client";
|
|
2
|
+
import type { WecomStoreDeps } from "./store";
|
|
1
3
|
// 企微登录领域服务:授权链接构造、state 一次性票据、code→userid、JIT 建档。
|
|
2
4
|
//
|
|
3
5
|
// 两条入口(都落在同一个回调上):
|
|
@@ -7,7 +9,14 @@
|
|
|
7
9
|
//
|
|
8
10
|
// JIT(无独立注册):外部身份沿用 `wecom:<userid>` 用户名前缀(D6),口令哈希占位 `!wecom`
|
|
9
11
|
// (任何口令都验不过,低信任来源不接管本地号,同 ldap 的 `!ldap`);外层租户由 corp_id 决定。
|
|
10
|
-
|
|
12
|
+
//
|
|
13
|
+
// 轨一「登录自更新」(方案 §6.1):登录成功后顺手 user/get 一次,补本人资料与所属部门。
|
|
14
|
+
// 四条纪律:① 24h 节流(老用户不重复打上游);② 整体 try/catch,失败不影响登录;
|
|
15
|
+
// ③ **不建占位部门**——只对已知部门写关联(D10,否则任何登录成员都能凭空造出部门节点);
|
|
16
|
+
// ④ 只加不摘:人调到新部门只加新的,旧部门由全量同步/事件摘除。
|
|
17
|
+
// 落库只有**一个**函数(applyMemberDetail):轨二(全量同步)、轨三(事件增量)也调它——
|
|
18
|
+
// 三套写库逻辑必然长歪,而症状只在「某条路径走过来的人组织关系不对」时才暴露。
|
|
19
|
+
import { setMainDepartment, upsertMember } from "#/_platform/_shared/service/domain/org";
|
|
11
20
|
|
|
12
21
|
export const AUTHORIZE_IN_APP = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
|
13
22
|
export const AUTHORIZE_QRCODE = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect";
|
|
@@ -99,6 +108,8 @@ export async function ensureWecomUser(
|
|
|
99
108
|
corp_id: corpId,
|
|
100
109
|
must_change: 0,
|
|
101
110
|
is_forbidden: 0,
|
|
111
|
+
// 走到这里说明真人正在扫码登录 → 这位是「用过本站」的活人,进通讯录目录
|
|
112
|
+
activated: 1,
|
|
102
113
|
}).run();
|
|
103
114
|
}
|
|
104
115
|
catch (e) {
|
|
@@ -135,3 +146,143 @@ export function loginLandingUrl(opts: { origin: string, ticket: string, tenant:
|
|
|
135
146
|
const q = new URLSearchParams({ wecom_ticket: opts.ticket, tenant: opts.tenant });
|
|
136
147
|
return `${opts.origin}/login?${q.toString()}`;
|
|
137
148
|
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// 轨一:登录自更新(方案 §6.1)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** 节流窗口:一个企业上万员工每天登录都会触发,不节流就是把 user/get 打成常驻流量 */
|
|
155
|
+
export const SELF_UPDATE_TTL_SECS = 24 * 60 * 60;
|
|
156
|
+
|
|
157
|
+
export interface SelfUpdateCtx {
|
|
158
|
+
uid: number
|
|
159
|
+
userid: string
|
|
160
|
+
corpId: string
|
|
161
|
+
agentId: string
|
|
162
|
+
permanentCode: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SelfUpdateDeps {
|
|
166
|
+
client: WecomClient
|
|
167
|
+
cache: WecomCache
|
|
168
|
+
store: WecomStoreDeps
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 企微在职状态里「不该再放进来」的两档:2=已禁用、5=退出企业(4=未激活仍可登录) */
|
|
172
|
+
function isBlockedStatus(status: number | null): boolean {
|
|
173
|
+
return status === 2 || status === 5;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function dbExternalStatus(userid: string): Promise<number | null> {
|
|
177
|
+
const rows = await db.table("users").select(["external_status"]).where({ field: "username", op: "eq", value: externalUsername(userid) }).all() as Array<{ external_status: number | null }>;
|
|
178
|
+
const v = rows[0]?.external_status;
|
|
179
|
+
return v == null ? null : Number(v);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 一条成员的「外部源口径」快照:登录自更新、全量同步、事件增量三处都写这一个形状 */
|
|
183
|
+
export interface MemberDetail {
|
|
184
|
+
/** undefined = 这条数据源没给名字,保留原值(空串/空白的名字不写) */
|
|
185
|
+
name?: string | null
|
|
186
|
+
/** undefined = 没给,**不写**(别把已知职务清成 null;null = 明确为空,写 null) */
|
|
187
|
+
position?: string | null
|
|
188
|
+
/** 同上:undefined = 不知道在职状态,不能覆盖 */
|
|
189
|
+
status?: number | null
|
|
190
|
+
departments: string[]
|
|
191
|
+
orders?: number[]
|
|
192
|
+
leaderInDept?: number[]
|
|
193
|
+
mainDepartment?: number | null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 把一条成员详情写回本地:资料(姓名/职务/在职状态)+ 所属部门关联。
|
|
198
|
+
*
|
|
199
|
+
* **三轨共用这一个写入口**(登录自更新 / 全量同步 / 事件增量):两套写库逻辑必然长歪——
|
|
200
|
+
* 一处漏了主部门兜底、一处漏了 D13 归属权,症状都是「某类路径走过来的人组织关系不对」,
|
|
201
|
+
* 而且只在特定组合下才出现。所以这里只认「一条快照」,不认它从哪个轨来。
|
|
202
|
+
*
|
|
203
|
+
* **只加不摘**:人调到新部门只写新部门,旧部门不在这里删——摘除属全量同步/事件增量的职责,
|
|
204
|
+
* 登录路径上删关联会在「企微接口这次只返回部分部门」时把人的组织关系误删。
|
|
205
|
+
* **只写企微拥有的行**(D13):`source != 'wecom'`(管理员已「转为本地维护」)一律不覆盖。
|
|
206
|
+
*/
|
|
207
|
+
export async function applyMemberDetail(
|
|
208
|
+
ctx: { uid: number, userid: string },
|
|
209
|
+
detail: MemberDetail,
|
|
210
|
+
opts: {
|
|
211
|
+
now: () => number
|
|
212
|
+
/** 真人刚登录过 → 纳入通讯录目录(§9.1)。同步与事件建档的人**不算**用过本站。 */
|
|
213
|
+
activated?: boolean
|
|
214
|
+
/** 全量同步按片预取的「企微部门 origin_id → 本地 id」;缺省时按本次涉及的部门现查 */
|
|
215
|
+
deptByOrigin?: Map<string, number>
|
|
216
|
+
},
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const cols: Record<string, unknown> = {};
|
|
219
|
+
if (detail.position !== undefined)
|
|
220
|
+
cols.position = detail.position;
|
|
221
|
+
if (detail.status !== undefined)
|
|
222
|
+
cols.external_status = detail.status;
|
|
223
|
+
if (opts.activated)
|
|
224
|
+
cols.activated = 1;
|
|
225
|
+
const displayName = detail.name?.trim();
|
|
226
|
+
if (displayName)
|
|
227
|
+
cols.display_name = displayName;
|
|
228
|
+
if (Object.keys(cols).length) {
|
|
229
|
+
await db.table("users").update(cols).where({ and: [
|
|
230
|
+
{ field: "username", op: "eq", value: externalUsername(ctx.userid) },
|
|
231
|
+
{ field: "source", op: "eq", value: "wecom" },
|
|
232
|
+
] }).run();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!detail.departments.length)
|
|
236
|
+
return;
|
|
237
|
+
const byOrigin = opts.deptByOrigin ?? await wecomDeptIndex(detail.departments);
|
|
238
|
+
const orgDeps = { now: opts.now };
|
|
239
|
+
for (const [i, originId] of detail.departments.entries()) {
|
|
240
|
+
const deptId = byOrigin.get(String(originId));
|
|
241
|
+
// D10:未知部门一律跳过,等全量同步补——这里若能建部门,任何登录成员都能伪造组织架构
|
|
242
|
+
if (!deptId)
|
|
243
|
+
continue;
|
|
244
|
+
await upsertMember({
|
|
245
|
+
userId: ctx.uid,
|
|
246
|
+
deptId,
|
|
247
|
+
orderNo: detail.orders?.[i] ?? 0,
|
|
248
|
+
isLeader: (detail.leaderInDept?.[i] ?? 0) === 1,
|
|
249
|
+
}, orgDeps);
|
|
250
|
+
}
|
|
251
|
+
// 主部门可能缺失(仅当应用对主部门有查看权限时才返回)→ 兜底取 department[0]
|
|
252
|
+
const mainOrigin = detail.mainDepartment != null ? String(detail.mainDepartment) : String(detail.departments[0]!);
|
|
253
|
+
const mainDept = byOrigin.get(mainOrigin);
|
|
254
|
+
if (mainDept != null)
|
|
255
|
+
await setMainDepartment(ctx.uid, mainDept, orgDeps);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function wecomDeptIndex(originIds: string[]): Promise<Map<string, number>> {
|
|
259
|
+
const rows = await db.table("sys_department").select(["id", "origin_id"]).where({ and: [
|
|
260
|
+
{ field: "origin", op: "eq", value: "wecom" },
|
|
261
|
+
{ field: "origin_id", op: "in", value: originIds },
|
|
262
|
+
] }).all() as Array<{ id: number, origin_id: string }>;
|
|
263
|
+
return new Map(rows.map(r => [String(r.origin_id), Number(r.id)]));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 登录自更新入口。**永不抛出**:任何失败只告警并放过登录(拿不到就用兜底值)。
|
|
268
|
+
* 返回值 `blocked` 供调用方在换会话前拦「企微侧已离职/禁用」的账号。
|
|
269
|
+
*/
|
|
270
|
+
export async function selfUpdate(ctx: SelfUpdateCtx, deps: SelfUpdateDeps): Promise<{ blocked: boolean }> {
|
|
271
|
+
const key = `wecom:self:${ctx.corpId}:${ctx.userid}`;
|
|
272
|
+
try {
|
|
273
|
+
if (await deps.cache.get(key))
|
|
274
|
+
return { blocked: isBlockedStatus(await dbExternalStatus(ctx.userid)) };
|
|
275
|
+
const detail = await deps.client.userDetail(ctx.corpId, ctx.agentId, ctx.permanentCode, ctx.userid);
|
|
276
|
+
// null = 无通讯录权限 / 不在可见范围 / 上游抖:本次不更新,也别写节流键(下次登录重试)
|
|
277
|
+
if (!detail)
|
|
278
|
+
return { blocked: isBlockedStatus(await dbExternalStatus(ctx.userid)) };
|
|
279
|
+
// 真人刚扫码登录过 → 纳入通讯录目录(§9.1);同步/事件建档的人不算
|
|
280
|
+
await applyMemberDetail(ctx, detail, { now: deps.store.now, activated: true });
|
|
281
|
+
await deps.cache.set(key, "1", SELF_UPDATE_TTL_SECS);
|
|
282
|
+
return { blocked: isBlockedStatus(detail.status) };
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
log.warn("wecom self-update skipped", "userid", ctx.userid, "reason", (e as Error).message);
|
|
286
|
+
return { blocked: false };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// 企微回调报文的入口归一化:把两种可能的包体(XML / JSON)折成同一份扁平字段表,
|
|
2
|
+
// 业务代码(`events.ts` / `wecom/api.ts`)只认字段名,不认格式。
|
|
3
|
+
//
|
|
4
|
+
// **按官方文档,我们这类回调收到的是 XML**:代开发 / 第三方应用所属的「通用回调配置」
|
|
5
|
+
// (96238 §3.2)只写 `<xml><ToUserName><Encrypt>`;`callback_format=json` 只在「对外收款」的
|
|
6
|
+
// 指令回调(97478)有说明。所以回调地址不带那个参数(`config.ts::eventUrlOf` 记了这段依据)。
|
|
7
|
+
//
|
|
8
|
+
// 那为什么还收 JSON:解密后的**明文**在多份业务文档里都有 JSON 版(如 92655 的 change_contact),
|
|
9
|
+
// 上游哪天默认切 JSON、或某类事件改发 JSON,这里不用动就能吃下。归一化只此一个入口,多一个分支
|
|
10
|
+
// 的成本;反过来「只吃 XML」意味着那天要回头改事件分发与端点接线。
|
|
11
|
+
//
|
|
12
|
+
// 为什么不用通用 XML 解析器:明文已由 AES-CBC + msg_signature 双重认证(crypt.ts),
|
|
13
|
+
// 这里只取扁平标签,且标签名由调用方常量给出——不解析结构、不处理命名空间,
|
|
14
|
+
// 用正则换掉一个解析器依赖与它的实体展开攻击面。
|
|
15
|
+
//
|
|
16
|
+
// 大小写:同一份报文里信封用小写(`{"tousername","encrypt","agentid"}`),密文里的明文
|
|
17
|
+
// 用大写(`{"SuiteId","InfoType"}`)。查字段一律走小写键,调用方不用关心这件事。
|
|
18
|
+
|
|
19
|
+
const XML_ENTITIES: Record<string, string> = {
|
|
20
|
+
amp: "&",
|
|
21
|
+
lt: "<",
|
|
22
|
+
gt: ">",
|
|
23
|
+
quot: "\"",
|
|
24
|
+
apos: "'",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function decodeEntities(s: string): string {
|
|
28
|
+
return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (whole, body: string) => {
|
|
29
|
+
if (body[0] === "#") {
|
|
30
|
+
const code = body[1] === "x" || body[1] === "X"
|
|
31
|
+
? Number.parseInt(body.slice(2), 16)
|
|
32
|
+
: Number.parseInt(body.slice(1), 10);
|
|
33
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
|
|
34
|
+
}
|
|
35
|
+
return XML_ENTITIES[body.toLowerCase()] ?? whole;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CallbackFields {
|
|
40
|
+
/** 小写键 → 值(查字段只走这里) */
|
|
41
|
+
values: Map<string, string>
|
|
42
|
+
/** 报文里出现过的字段名(原样大小写、去重保序),只用于日志取证 */
|
|
43
|
+
names: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 取字段;未命中空串(「没这个字段」与「字段为空」在回调处理里不需要区分) */
|
|
47
|
+
export function field(fields: CallbackFields, name: string): string {
|
|
48
|
+
return fields.values.get(name.toLowerCase()) ?? "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 必填字段:缺了直接 400。文案是给运维排查用的(企微侧只会显示「应用安装失败」),
|
|
53
|
+
* 不含值——明文字段里有姓名与工号。
|
|
54
|
+
*/
|
|
55
|
+
export function requireField(fields: CallbackFields, name: string, infoType: string): string {
|
|
56
|
+
const value = field(fields, name);
|
|
57
|
+
if (!value)
|
|
58
|
+
throw Object.assign(new Error(`企微事件 ${infoType} 缺少 ${name}`), { status: 400 });
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCallbackFields(v: unknown): v is CallbackFields {
|
|
63
|
+
return typeof v === "object" && v !== null && (v as CallbackFields).values instanceof Map;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 只取「叶子节点」:非 CDATA 分支明确排除 `<`,于是 `<xml><Encrypt>…</Encrypt></xml>` 里的
|
|
67
|
+
// 根标签匹配不上(它内部以 `<` 开头),正则会继续向内找到 `<Encrypt>`。换成 `[\s\S]*?` 会让
|
|
68
|
+
// 根标签把整段吞成一个字段(名叫 `xml`),内层字段全部丢失——这是最容易写错的一处。
|
|
69
|
+
const XML_FIELD_RE = /<([A-Z_]\w*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/gi;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 归一回调包体。接受四种输入:已是归一结果的字段表(幂等,直接返回)、oj 已解析好的对象、
|
|
73
|
+
* JSON 字符串、XML 字符串。
|
|
74
|
+
*
|
|
75
|
+
* 都解不出字段时**不在这里抛错**:调用方按「缺 Encrypt」回 400,那条文案比
|
|
76
|
+
* 「JSON 解析失败」更好排查(能直接指向回调地址被中间件改写这种事)。
|
|
77
|
+
*/
|
|
78
|
+
export function callbackFields(raw: unknown): CallbackFields {
|
|
79
|
+
if (isCallbackFields(raw))
|
|
80
|
+
return raw;
|
|
81
|
+
const values = new Map<string, string>();
|
|
82
|
+
const names: string[] = [];
|
|
83
|
+
const put = (name: string, value: unknown) => {
|
|
84
|
+
if (!names.includes(name))
|
|
85
|
+
names.push(name);
|
|
86
|
+
values.set(name.toLowerCase(), value == null ? "" : String(value));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (typeof raw === "string") {
|
|
90
|
+
const text = raw.trim();
|
|
91
|
+
if (text.startsWith("{")) {
|
|
92
|
+
try {
|
|
93
|
+
for (const [k, v] of Object.entries(JSON.parse(text) as Record<string, unknown>))
|
|
94
|
+
put(k, v);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { values, names };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const m of text.matchAll(XML_FIELD_RE))
|
|
102
|
+
put(m[1]!, decodeEntities((m[2] ?? m[3] ?? "").trim()));
|
|
103
|
+
}
|
|
104
|
+
return { values, names };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (raw && typeof raw === "object") {
|
|
108
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
109
|
+
// 嵌套对象不是企微回调的形态(字段全平铺);遇到就跳过,别把 [object Object] 塞进字段表
|
|
110
|
+
if (v !== null && typeof v === "object")
|
|
111
|
+
continue;
|
|
112
|
+
put(k, v);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { values, names };
|
|
116
|
+
}
|
|
@@ -27,6 +27,8 @@ export interface AppProfile {
|
|
|
27
27
|
corpName: string | null
|
|
28
28
|
status: "active" | "disabled"
|
|
29
29
|
permanentCode: string
|
|
30
|
+
/** 0=管理员授权 / 1=成员授权(成员授权下应用无任何部门权限,组织架构同步必然 60011,§2.3) */
|
|
31
|
+
authMode: number
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/** 套件票据:企微每 10 分钟推一次,有效期 30 分钟;过期不得发起任何套件级 API 调用 */
|
|
@@ -120,6 +122,8 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
|
|
|
120
122
|
corp_name: p.corpName,
|
|
121
123
|
status: p.status ?? "active",
|
|
122
124
|
permanent_code_enc: deps.encrypt(p.permanentCode),
|
|
125
|
+
// 授权模式是「该企业应用能不能做组织架构同步」的判据(§2.3);重授权时必须跟着刷新
|
|
126
|
+
auth_mode: Number(p.authMode ?? 0),
|
|
123
127
|
update_time: now,
|
|
124
128
|
};
|
|
125
129
|
if (rows.length) {
|
|
@@ -161,6 +165,43 @@ export async function findProfile(corpId: string, agentId: string | null, deps:
|
|
|
161
165
|
corpName: r.corp_name == null ? null : String(r.corp_name),
|
|
162
166
|
status: String(r.status) === "disabled" ? "disabled" : "active",
|
|
163
167
|
permanentCode: deps.decrypt(String(r.permanent_code_enc)),
|
|
168
|
+
authMode: Number(r.auth_mode ?? 0),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 「这家企业能不能同步」的三个事实:装没装 / 什么授权模式 / 是否还启用 */
|
|
173
|
+
export interface ProfileProbe {
|
|
174
|
+
agentId: string
|
|
175
|
+
authMode: number
|
|
176
|
+
status: AppProfile["status"]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 授权探针(组织架构同步面的准入用):与 `findProfile` 只差一件事——**查不到返回 null 而不是 400**。
|
|
181
|
+
*
|
|
182
|
+
* 为什么敢把「没有」当正常状态:调用方是本租户管理员在自己的组织架构页自省,`corp_id`
|
|
183
|
+
* 取自租户头而不是外部输入,不存在 `findProfile` 要防的枚举面(§5.3)。「本站没装企微」
|
|
184
|
+
* 是一个要能画成空态的事实,不是错误。
|
|
185
|
+
*
|
|
186
|
+
* 选中口径与 `findProfile` 对齐(优选启用中的应用;全停用时退回停用行,让页面能画
|
|
187
|
+
* 「已取消授权」冻结态),只多一条:没有启用应用时也返回,而不是 400。
|
|
188
|
+
*/
|
|
189
|
+
export async function probeProfile(corpId: string, agentId: string | null): Promise<ProfileProbe | null> {
|
|
190
|
+
const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
|
|
191
|
+
if (rows.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const active = rows.filter(r => String(r.status) === "active");
|
|
194
|
+
const pool = active.length > 0 ? active : rows;
|
|
195
|
+
const picked = agentId ? pool.filter(r => String(r.agent_id) === agentId) : pool;
|
|
196
|
+
if (picked.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
if (picked.length > 1)
|
|
199
|
+
fail(400, "该企业开通了多个应用,同步入口必须带 agent 参数");
|
|
200
|
+
const r = picked[0]!;
|
|
201
|
+
return {
|
|
202
|
+
agentId: String(r.agent_id),
|
|
203
|
+
authMode: Number(r.auth_mode ?? 0),
|
|
204
|
+
status: String(r.status) === "disabled" ? "disabled" : "active",
|
|
164
205
|
};
|
|
165
206
|
}
|
|
166
207
|
|
|
@@ -169,6 +210,30 @@ export async function setProfileStatus(corpId: string, status: AppProfile["statu
|
|
|
169
210
|
return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
|
|
170
211
|
}
|
|
171
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 该企业已授权的应用 + 共享的永久授权码。
|
|
215
|
+
* 用于 `change_auth` 复核授权模式:事件只带 AuthCorpId(**没有 AuthCode**),
|
|
216
|
+
* 只能拿已落库的 permanent_code 去 `get_auth_info` 反查。未授权企业返回 null。
|
|
217
|
+
*
|
|
218
|
+
* 一并返回 `corpId`(真实企业 ID):事件里给的可能是 `AuthCorpId`(第三方体系下是 open_corpid),
|
|
219
|
+
* 而租户键只能用真实 corp_id(D10),故由这里统一把「事件身份 → 租户身份」的翻译交出来。
|
|
220
|
+
*/
|
|
221
|
+
export async function findCorpAuth(corpId: string, deps: WecomStoreDeps): Promise<{ corpId: string, permanentCode: string, agentIds: string[] } | null> {
|
|
222
|
+
const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
|
|
223
|
+
if (rows.length === 0)
|
|
224
|
+
return null;
|
|
225
|
+
return {
|
|
226
|
+
corpId: String(rows[0]!.corp_id),
|
|
227
|
+
permanentCode: deps.decrypt(String(rows[0]!.permanent_code_enc)),
|
|
228
|
+
agentIds: rows.map(r => String(r.agent_id)),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 授权模式变更:只改 auth_mode,不动 secret / 状态(改状态是 cancel_auth 的事) */
|
|
233
|
+
export async function setAuthMode(corpId: string, agentId: string, authMode: number, deps: WecomStoreDeps): Promise<void> {
|
|
234
|
+
await db.table("wecom_app_profile").update({ auth_mode: Number(authMode), update_time: deps.now() }).where(corpAgentScope(corpId, agentId)).run();
|
|
235
|
+
}
|
|
236
|
+
|
|
172
237
|
export async function putSuiteTicket(suiteId: string, ticket: string, expiresAt: number, deps: WecomStoreDeps): Promise<void> {
|
|
173
238
|
const rows = await db.table("wecom_suite_ticket").select(["suite_id"]).where({ field: "suite_id", op: "eq", value: suiteId }).all();
|
|
174
239
|
const cols = { suite_ticket: ticket, expires_at: expiresAt, update_time: deps.now() };
|