@oj-module/cli 0.2.47 → 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 +237 -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__/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/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 +243 -31
- 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 +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 +3 -1
- 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/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 +115 -8
- 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
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// 企微组织架构**全量同步**(B2 / 方案 §6.2):唯一权威来源。
|
|
2
|
+
//
|
|
3
|
+
// 形态是「分片推进」而不是「一个长任务」——服务端 isolate 没有定时器,路由也有超时,
|
|
4
|
+
// 所以一次请求只推一片(默认 12 秒的预算),进度落在 wecom_sync_job 的游标里,
|
|
5
|
+
// 由调用方轮询驱动。好处是:可续跑、可观测、不受路由超时限制、崩了也不丢进度。
|
|
6
|
+
//
|
|
7
|
+
// 三个阶段(相序固定,游标就是在这三个之间迁移):
|
|
8
|
+
// departments → 一次 simplelist 拿可见范围内全部部门(兜底 department/list),按父子序落库;
|
|
9
|
+
// members → 逐部门 user/list(要职务/排序/主部门,只有重量接口有),按片处理;
|
|
10
|
+
// reconcile → **仅当本轮完整且零跳过**才把上一轮见过、这一轮没见的部门置隐(D9)。
|
|
11
|
+
//
|
|
12
|
+
// 三条纪律:
|
|
13
|
+
// · 归属权(D13):所有写路径都带 `origin = 'wecom'`,本地建的部门一根毛都不碰;
|
|
14
|
+
// · 错码三分(§5.1):权限类跳过并计数、限流类退避(client 内已重试,还错就停片等下轮)、
|
|
15
|
+
// 致命类计入熔断(连续 FATAL_LIMIT 次 → job failed);
|
|
16
|
+
// · 错误落库只存脱敏结构(`{errcode, phase, deptId, at}`):异常原文里可能夹带 URL 上的 token。
|
|
17
|
+
import type { WecomClient, WecomMember } from "./client";
|
|
18
|
+
import type { ProfileProbe } from "./store";
|
|
19
|
+
|
|
20
|
+
import { markSeen, reconcileHidden, upsertDepartment } from "#/_platform/_shared/service/domain/org";
|
|
21
|
+
import { upsertSourceUser } from "#/_platform/_shared/service/domain/users";
|
|
22
|
+
import { WecomApiError } from "./client";
|
|
23
|
+
import { applyMemberDetail, externalUsername } from "./login";
|
|
24
|
+
|
|
25
|
+
export type SyncStatus = "idle" | "running" | "done" | "failed" | "cancelled";
|
|
26
|
+
export type SyncPhase = "departments" | "members" | "reconcile";
|
|
27
|
+
|
|
28
|
+
export interface SyncStats {
|
|
29
|
+
/** 本轮落库的部门数 */
|
|
30
|
+
departments: number
|
|
31
|
+
/** 本轮结束时本企业的企微来源账号数(不是「人×部门」的调用次数) */
|
|
32
|
+
members: number
|
|
33
|
+
/** 因权限(60011/48002)跳过成员列表的部门数——页面要显示「N 个部门因权限跳过」 */
|
|
34
|
+
skipped: number
|
|
35
|
+
/** 是否执行了墓碑回收(有跳过就不敢回收,见 D9) */
|
|
36
|
+
reconciled: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SyncCursor {
|
|
40
|
+
phase: SyncPhase
|
|
41
|
+
/**
|
|
42
|
+
* 轮次号(单调递增,落 `sys_department.seen_job_id`)。
|
|
43
|
+
* 不用时间戳:同秒内重跑一轮会让「上一轮见过」和「本轮见过」撞成同一个值,
|
|
44
|
+
* 墓碑回收(`seen_job_id < round`)就永远不生效。
|
|
45
|
+
*/
|
|
46
|
+
round: number
|
|
47
|
+
/** departments 阶段产出的部门清单;members 阶段按它逐部门拉人(游标只存本地 id + 企微 id) */
|
|
48
|
+
depts: Array<{ id: number, originId: string }>
|
|
49
|
+
/** 已处理到 depts 的下标(续跑锚点,避免同一部门重复拉) */
|
|
50
|
+
at: number
|
|
51
|
+
/** 连续致命错误计数(熔断) */
|
|
52
|
+
fatalRun: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 同步任务本身的状态(只来自 `wecom_sync_job` 这一张表) */
|
|
56
|
+
export interface SyncJobView {
|
|
57
|
+
status: SyncStatus
|
|
58
|
+
phase: SyncPhase
|
|
59
|
+
stats: SyncStats
|
|
60
|
+
/** 进度:done/total 均以「部门」计(成员阶段按已处理部门推进) */
|
|
61
|
+
progress: { done: number, total: number }
|
|
62
|
+
/** 脱敏后的错码与明细;非 failed 时为空 */
|
|
63
|
+
errCode: string | null
|
|
64
|
+
errDetail: { errcode?: number, phase: SyncPhase, deptId?: string, at: number } | null
|
|
65
|
+
startedAt: number
|
|
66
|
+
finishedAt: number
|
|
67
|
+
updateTime: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 给页面看的完整状态 = 任务状态 + 授权事实。
|
|
72
|
+
* 两者刻意分开:`status` 是「同步跑到哪了」,`connected`/`authMode` 是「这家企业能不同步」,
|
|
73
|
+
* 后者来自授权面、前者来自执行面,混在一张表里就会在「取消授权后同步状态还写着 done」时自相矛盾。
|
|
74
|
+
*/
|
|
75
|
+
export interface SyncView extends SyncJobView {
|
|
76
|
+
/** 本站是否装过企微(有授权记录,含已停用)——false 时页面整条不显示 */
|
|
77
|
+
connected: boolean
|
|
78
|
+
/** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝) */
|
|
79
|
+
authMode: number
|
|
80
|
+
/** 授权记录的应用状态;disabled = 已取消授权/被停用,同步冻结但数据保留(§7 黄条) */
|
|
81
|
+
profileStatus: "active" | "disabled" | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SyncDeps {
|
|
85
|
+
client: WecomClient
|
|
86
|
+
now: () => number
|
|
87
|
+
/** 单片时间预算(测试可调小;缺省见 DEFAULT_BUDGET_MS) */
|
|
88
|
+
budgetMs?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ORIGIN = "wecom";
|
|
92
|
+
/**
|
|
93
|
+
* 单片的墙钟预算。定 12 秒是两头夹出来的:
|
|
94
|
+
* · 上界——路由全局超时 30 秒(`config.yaml server.timeout` 缺省 "30s"),而单次上游调用
|
|
95
|
+
* 自带 5 秒软超时(client.ts TIMEOUT_MS),预算到期后最多再等一次调用,12+5 < 30;
|
|
96
|
+
* · 下界——4 秒(初版)配合 5 次/分的限速,前端串行驱动第 6 次就被 429 掐断,
|
|
97
|
+
* 500 人的企业要跑好几分钟;12 秒下一片能干完 100+ 次上游调用,正常规模两三次到底。
|
|
98
|
+
*/
|
|
99
|
+
const DEFAULT_BUDGET_MS = 12000;
|
|
100
|
+
/** 连续致命错误上限(方案 §5.1「连续 10 次 → failed」) */
|
|
101
|
+
const FATAL_LIMIT = 10;
|
|
102
|
+
/** 抢锁租约:单片最长 12+5 秒,120 秒留给「请求被平台强杀、finally 都没跑到」的场景 */
|
|
103
|
+
const LOCK_TTL_SECS = 120;
|
|
104
|
+
|
|
105
|
+
function fail(status: number, message: string): never {
|
|
106
|
+
throw Object.assign(new Error(message), { status });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function emptyStats(): SyncStats {
|
|
110
|
+
return { departments: 0, members: 0, skipped: 0, reconciled: false };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emptyCursor(): SyncCursor {
|
|
114
|
+
return { phase: "departments", round: 0, depts: [], at: 0, fatalRun: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseJson<T>(raw: unknown, fallback: T): T {
|
|
118
|
+
if (raw == null || raw === "")
|
|
119
|
+
return fallback;
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(String(raw)) as T;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return fallback;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface JobRow {
|
|
129
|
+
status: string
|
|
130
|
+
phase: string
|
|
131
|
+
cursor_text: string | null
|
|
132
|
+
stats_text: string | null
|
|
133
|
+
err_code: string | null
|
|
134
|
+
err_detail: string | null
|
|
135
|
+
started_at: number
|
|
136
|
+
finished_at: number
|
|
137
|
+
update_time: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function jobScope(corpId: string, agentId: string) {
|
|
141
|
+
return {
|
|
142
|
+
and: [
|
|
143
|
+
{ field: "corp_id", op: "eq", value: corpId },
|
|
144
|
+
{ field: "agent_id", op: "eq", value: agentId },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readJob(corpId: string, agentId: string): Promise<JobRow | null> {
|
|
150
|
+
const rows = await db.table("wecom_sync_job").select(["*"]).where(jobScope(corpId, agentId)).all() as unknown as JobRow[];
|
|
151
|
+
return rows[0] ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toView(job: JobRow | null): SyncJobView {
|
|
155
|
+
if (!job) {
|
|
156
|
+
return {
|
|
157
|
+
status: "idle",
|
|
158
|
+
phase: "departments",
|
|
159
|
+
stats: emptyStats(),
|
|
160
|
+
progress: { done: 0, total: 0 },
|
|
161
|
+
errCode: null,
|
|
162
|
+
errDetail: null,
|
|
163
|
+
startedAt: 0,
|
|
164
|
+
finishedAt: 0,
|
|
165
|
+
updateTime: 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const cursor = parseJson<SyncCursor>(job.cursor_text, emptyCursor());
|
|
169
|
+
return {
|
|
170
|
+
status: (job.status as SyncStatus) ?? "idle",
|
|
171
|
+
phase: (job.phase as SyncPhase) ?? cursor.phase,
|
|
172
|
+
stats: parseJson<SyncStats>(job.stats_text, emptyStats()),
|
|
173
|
+
progress: { done: cursor.at, total: cursor.depts.length },
|
|
174
|
+
errCode: job.err_code,
|
|
175
|
+
errDetail: parseJson<SyncJobView["errDetail"]>(job.err_detail, null),
|
|
176
|
+
startedAt: Number(job.started_at ?? 0),
|
|
177
|
+
finishedAt: Number(job.finished_at ?? 0),
|
|
178
|
+
updateTime: Number(job.update_time ?? 0),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 当前进度(只读本地行,不打上游):轮询用它,不用 start */
|
|
183
|
+
export async function syncStatus(corpId: string, agentId: string): Promise<SyncJobView> {
|
|
184
|
+
return toView(await readJob(corpId, agentId));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 拼上授权事实(见 SyncView 注释)。probe 为 null = 本站没装企微 */
|
|
188
|
+
export function attachProfile(view: SyncJobView, probe: ProfileProbe | null): SyncView {
|
|
189
|
+
return {
|
|
190
|
+
...view,
|
|
191
|
+
connected: probe !== null,
|
|
192
|
+
authMode: probe?.authMode ?? 0,
|
|
193
|
+
profileStatus: probe?.status ?? null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function writeJob(corpId: string, agentId: string, patch: Record<string, unknown>, now: number): Promise<void> {
|
|
198
|
+
const cols = { ...patch, update_time: now };
|
|
199
|
+
const rows = await db.table("wecom_sync_job").select(["agent_id"]).where(jobScope(corpId, agentId)).all();
|
|
200
|
+
if (rows.length) {
|
|
201
|
+
await db.table("wecom_sync_job").update(cols).where(jobScope(corpId, agentId)).run();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await db.table("wecom_sync_job").insert({ corp_id: corpId, agent_id: agentId, started_at: now, finished_at: 0, ...cols }).run();
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
// 唯一约束撞车(两片同时起):转为更新,同 saveProfile 先例
|
|
209
|
+
if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
|
|
210
|
+
throw e;
|
|
211
|
+
await db.table("wecom_sync_job").update(cols).where(jobScope(corpId, agentId)).run();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 抢锁:**靠复合主键插入**判原子,不靠读 status 再改。
|
|
217
|
+
* 过期租约(请求崩溃留下的行)允许抢占——否则一次 OOM 会让这个企业永远不能同步。
|
|
218
|
+
*/
|
|
219
|
+
async function acquireLock(agentId: string, owner: string, now: number): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await db.table("wecom_sync_lock").insert({ agent_id: agentId, owner, taken_at: now }).run();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
const rows = await db.table("wecom_sync_lock").select(["taken_at"]).where({ field: "agent_id", op: "eq", value: agentId }).all() as Array<{ taken_at: number }>;
|
|
229
|
+
const takenAt = Number(rows[0]?.taken_at ?? 0);
|
|
230
|
+
if (now - takenAt < LOCK_TTL_SECS)
|
|
231
|
+
fail(409, "同步正在进行,请稍后再试");
|
|
232
|
+
await db.table("wecom_sync_lock").update({ owner, taken_at: now }).where({ field: "agent_id", op: "eq", value: agentId }).run();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function releaseLock(agentId: string): Promise<void> {
|
|
236
|
+
await db.table("wecom_sync_lock").where({ field: "agent_id", op: "eq", value: agentId }).delete().run();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 部门清单:主路径 `department/simplelist`(不带父 id = 应用可见范围内全部部门,一次拿完)。
|
|
241
|
+
* 上游明确写过 `department/list` 性能低(90208),故只当兜底——它多返回 `order`,
|
|
242
|
+
* 但大企业回包更容易撞 45029。
|
|
243
|
+
*/
|
|
244
|
+
async function fetchDepartments(client: WecomClient, corpId: string, agentId: string, secret: string) {
|
|
245
|
+
try {
|
|
246
|
+
return await client.departmentChildren(corpId, agentId, secret);
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
if (e instanceof WecomApiError && e.kind === "permission")
|
|
250
|
+
throw e;
|
|
251
|
+
return await client.departmentList(corpId, agentId, secret);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 按父子序排好再落库:`upsertDepartment` 要的是**本地父 id**,所以父必须先存在。
|
|
257
|
+
* 孤儿(父不在结果集,通常是可见范围导致的)按方案挂到根,不报错;真成环的兜底也挂根
|
|
258
|
+
* (企微侧不会给出环,但这里绝不能死循环)。
|
|
259
|
+
*/
|
|
260
|
+
function orderTopDown<T extends { id: string, parentId: string }>(list: T[]): T[] {
|
|
261
|
+
const pending = new Map<string, T>(list.map(d => [d.id, d]));
|
|
262
|
+
const resolved = new Set<string>();
|
|
263
|
+
const out: T[] = [];
|
|
264
|
+
while (pending.size) {
|
|
265
|
+
let progressed = false;
|
|
266
|
+
for (const [id, d] of pending) {
|
|
267
|
+
const parent = d.parentId;
|
|
268
|
+
// 父为空、父不在本批(孤儿)、或父已排好 → 现在就能落
|
|
269
|
+
if (!parent || !pending.has(parent) || resolved.has(parent)) {
|
|
270
|
+
out.push(d);
|
|
271
|
+
resolved.add(id);
|
|
272
|
+
pending.delete(id);
|
|
273
|
+
progressed = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!progressed) {
|
|
277
|
+
for (const d of pending.values())
|
|
278
|
+
out.push(d);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function scrubError(e: unknown, phase: SyncPhase, deptId: string | null, now: number): { errCode: string, errDetail: SyncJobView["errDetail"] } {
|
|
286
|
+
if (e instanceof WecomApiError)
|
|
287
|
+
return { errCode: String(e.errcode), errDetail: { errcode: e.errcode, phase, deptId: deptId ?? undefined, at: now } };
|
|
288
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
289
|
+
// 非企微错码(网络/超时):只留类别,不留原文(原文可能带 URL 上的 access_token)
|
|
290
|
+
return { errCode: /超时/.test(message) ? "timeout" : "network", errDetail: { phase, deptId: deptId ?? undefined, at: now } };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 没有 job 行时的空视图(导出给「本站没装企微」的空态用) */
|
|
294
|
+
export function emptyView(status: SyncStatus): SyncJobView {
|
|
295
|
+
return { ...toView(null), status };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 开始 / 继续一次同步——**每调一次推一片**。
|
|
300
|
+
*
|
|
301
|
+
* 语义取「开始或继续」而不是「必须从零开始」:可续跑是这套分片设计的全部意义,
|
|
302
|
+
* 前端只要在 `status === "running"` 时继续调它(或由用户点「继续」),直到 done/failed。
|
|
303
|
+
*/
|
|
304
|
+
export async function startSync(ctx: { corpId: string, agentId: string, secret: string, authMode: number }, deps: SyncDeps): Promise<SyncJobView> {
|
|
305
|
+
// 准入判据:成员授权(auth_mode=1)下应用无任何部门权限,跑起来只会得到一片 60011
|
|
306
|
+
if (Number(ctx.authMode) !== 0)
|
|
307
|
+
fail(400, "该企业为「成员授权」模式,应用没有通讯录权限:请企业管理员在企微后台把授权模式改为「管理员授权」后再同步");
|
|
308
|
+
|
|
309
|
+
const now = Math.floor(deps.now() / 1000);
|
|
310
|
+
await acquireLock(ctx.agentId, `u${Number((globalThis as { http?: { user?: { id?: number } } }).http?.user?.id ?? 0)}`, now);
|
|
311
|
+
try {
|
|
312
|
+
const existing = await readJob(ctx.corpId, ctx.agentId);
|
|
313
|
+
const resumable = existing && existing.status === "running";
|
|
314
|
+
const prevCursor = existing ? parseJson<SyncCursor>(existing.cursor_text, emptyCursor()) : null;
|
|
315
|
+
let cursor = resumable && prevCursor
|
|
316
|
+
? prevCursor
|
|
317
|
+
: { ...emptyCursor(), round: (prevCursor?.round ?? 0) + 1 };
|
|
318
|
+
let stats = resumable ? parseJson<SyncStats>(existing.stats_text, emptyStats()) : emptyStats();
|
|
319
|
+
const startedAt = resumable ? Number(existing.started_at) : now;
|
|
320
|
+
const roundId = cursor.round;
|
|
321
|
+
|
|
322
|
+
await writeJob(ctx.corpId, ctx.agentId, {
|
|
323
|
+
status: "running",
|
|
324
|
+
phase: cursor.phase,
|
|
325
|
+
cursor_text: JSON.stringify(cursor),
|
|
326
|
+
stats_text: JSON.stringify(stats),
|
|
327
|
+
err_code: null,
|
|
328
|
+
err_detail: null,
|
|
329
|
+
started_at: startedAt,
|
|
330
|
+
finished_at: 0,
|
|
331
|
+
}, now);
|
|
332
|
+
|
|
333
|
+
const result = await runSlice(ctx.corpId, ctx.agentId, ctx.secret, cursor, stats, roundId, deps);
|
|
334
|
+
cursor = result.cursor;
|
|
335
|
+
stats = result.stats;
|
|
336
|
+
// 三态收敛:失败 / 片内收手(还在 running,等下一次 start)/ 全部跑完
|
|
337
|
+
const status: SyncStatus = result.failed ? "failed" : result.paused ? "running" : "done";
|
|
338
|
+
await writeJob(ctx.corpId, ctx.agentId, {
|
|
339
|
+
status,
|
|
340
|
+
phase: cursor.phase,
|
|
341
|
+
cursor_text: JSON.stringify(cursor),
|
|
342
|
+
stats_text: JSON.stringify(stats),
|
|
343
|
+
err_code: result.errCode,
|
|
344
|
+
err_detail: result.errDetail ? JSON.stringify(result.errDetail) : null,
|
|
345
|
+
finished_at: status === "running" ? 0 : now,
|
|
346
|
+
}, now);
|
|
347
|
+
return toView(await readJob(ctx.corpId, ctx.agentId));
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await releaseLock(ctx.agentId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
interface SliceResult {
|
|
355
|
+
cursor: SyncCursor
|
|
356
|
+
stats: SyncStats
|
|
357
|
+
failed: boolean
|
|
358
|
+
/** 片内收手(预算用完 / 限流 / 可恢复的致命错):进度已落库,下次接着推 */
|
|
359
|
+
paused: boolean
|
|
360
|
+
errCode: string | null
|
|
361
|
+
errDetail: SyncJobView["errDetail"]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function runSlice(
|
|
365
|
+
corpId: string,
|
|
366
|
+
agentId: string,
|
|
367
|
+
secret: string,
|
|
368
|
+
cursor: SyncCursor,
|
|
369
|
+
stats: SyncStats,
|
|
370
|
+
roundId: number,
|
|
371
|
+
deps: SyncDeps,
|
|
372
|
+
): Promise<SliceResult> {
|
|
373
|
+
const orgDeps = { now: deps.now };
|
|
374
|
+
const deadline = deps.now() + (deps.budgetMs ?? DEFAULT_BUDGET_MS);
|
|
375
|
+
const done = (extra: Partial<SliceResult> = {}): SliceResult => ({ cursor, stats, failed: false, paused: false, errCode: null, errDetail: null, ...extra });
|
|
376
|
+
const paused = (): SliceResult => done({ paused: true });
|
|
377
|
+
|
|
378
|
+
if (cursor.phase === "departments") {
|
|
379
|
+
let list: Array<{ id: string, parentId: string, name: string | null, order: number }>;
|
|
380
|
+
try {
|
|
381
|
+
list = await fetchDepartments(deps.client, corpId, agentId, secret);
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
const scrubbed = scrubError(e, "departments", null, Math.floor(deps.now() / 1000));
|
|
385
|
+
return done({ failed: true, errCode: scrubbed.errCode, errDetail: scrubbed.errDetail });
|
|
386
|
+
}
|
|
387
|
+
const ordered = orderTopDown(list);
|
|
388
|
+
const localByOrigin = new Map<string, number>();
|
|
389
|
+
const placedIds: number[] = [];
|
|
390
|
+
for (const d of ordered) {
|
|
391
|
+
const parentLocal = d.parentId && localByOrigin.has(d.parentId) ? localByOrigin.get(d.parentId)! : null;
|
|
392
|
+
const id = await upsertDepartment({ origin: ORIGIN, originId: d.id, parentId: parentLocal, name: d.name, orderNo: d.order }, orgDeps);
|
|
393
|
+
localByOrigin.set(d.id, id);
|
|
394
|
+
placedIds.push(id);
|
|
395
|
+
}
|
|
396
|
+
await markSeen(ORIGIN, placedIds, roundId, orgDeps);
|
|
397
|
+
cursor.depts = ordered.map(d => ({ id: localByOrigin.get(d.id)!, originId: d.id }));
|
|
398
|
+
cursor.at = 0;
|
|
399
|
+
cursor.phase = "members";
|
|
400
|
+
stats.departments = cursor.depts.length;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (cursor.phase === "members") {
|
|
404
|
+
// 部门 id 映射:一次查询覆盖整片(500 部门以内一条 SQL;逐人查会把同步打成 N×M 次)
|
|
405
|
+
const rows = await db.table("sys_department").select(["id", "origin_id"]).where({ field: "origin", op: "eq", value: ORIGIN }).all() as Array<{ id: number, origin_id: string }>;
|
|
406
|
+
const deptByOrigin = new Map(rows.map(r => [String(r.origin_id), Number(r.id)]));
|
|
407
|
+
while (cursor.at < cursor.depts.length) {
|
|
408
|
+
if (deps.now() >= deadline)
|
|
409
|
+
return paused();
|
|
410
|
+
const dept = cursor.depts[cursor.at]!;
|
|
411
|
+
let members: WecomMember[];
|
|
412
|
+
try {
|
|
413
|
+
members = await deps.client.deptMembersDetail(corpId, agentId, secret, dept.originId);
|
|
414
|
+
cursor.fatalRun = 0;
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
const kind = e instanceof WecomApiError ? e.kind : "fatal";
|
|
418
|
+
if (kind === "permission") {
|
|
419
|
+
// 单个部门无权限是常态(可见范围外的部门):跳过并计数,不升级成失败
|
|
420
|
+
stats.skipped++;
|
|
421
|
+
cursor.at++;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (kind === "ratelimit")
|
|
425
|
+
return paused(); // 限流已在 client 内退避三次,还错就停片,游标留在本部门
|
|
426
|
+
cursor.fatalRun++;
|
|
427
|
+
if (cursor.fatalRun >= FATAL_LIMIT) {
|
|
428
|
+
const scrubbed = scrubError(e, "members", dept.originId, Math.floor(deps.now() / 1000));
|
|
429
|
+
return done({ failed: true, errCode: scrubbed.errCode, errDetail: scrubbed.errDetail });
|
|
430
|
+
}
|
|
431
|
+
return paused();
|
|
432
|
+
}
|
|
433
|
+
for (const m of members)
|
|
434
|
+
await applyMember(m, corpId, deptByOrigin, orgDeps);
|
|
435
|
+
cursor.at++;
|
|
436
|
+
}
|
|
437
|
+
cursor.phase = "reconcile";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (cursor.phase === "reconcile") {
|
|
441
|
+
// D9:只有「本轮完整且零跳过」才敢回收——否则一次上游抖动会被读成「这些部门被删了」
|
|
442
|
+
if (stats.skipped === 0) {
|
|
443
|
+
await reconcileHidden(ORIGIN, roundId, orgDeps);
|
|
444
|
+
stats.reconciled = true;
|
|
445
|
+
}
|
|
446
|
+
stats.members = await countMembers(corpId);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return done();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function countMembers(corpId: string): Promise<number> {
|
|
453
|
+
const rows = await db.table("users").select(["id"]).where({ and: [
|
|
454
|
+
{ field: "source", op: "eq", value: ORIGIN },
|
|
455
|
+
{ field: "corp_id", op: "eq", value: corpId },
|
|
456
|
+
] }).all();
|
|
457
|
+
return rows.length;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 一个成员:建档(activated=0)→ 交给 `applyMemberDetail` 写资料与组织关系。
|
|
462
|
+
*
|
|
463
|
+
* 这里**只做「建档 + 传参」**:真正的字段口径(只写自己拥有的行、未知部门跳过、主部门兜底)
|
|
464
|
+
* 在 login.ts::applyMemberDetail 里,登录自更新与事件增量也调它。deptByOrigin 是本片预取的,
|
|
465
|
+
* 逐人去查会把这个 500 部门的同步打成 N×M 次查询。
|
|
466
|
+
*/
|
|
467
|
+
async function applyMember(m: WecomMember, corpId: string, deptByOrigin: Map<string, number>, orgDeps: { now: () => number }): Promise<void> {
|
|
468
|
+
const uid = await upsertSourceUser({ username: externalUsername(m.userid), displayName: m.name, source: ORIGIN, corpId });
|
|
469
|
+
await applyMemberDetail({ uid, userid: m.userid }, m, { ...orgDeps, deptByOrigin });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 取消:清游标、置 cancelled(不回滚已写入的组织数据——同步幂等,重跑即可) */
|
|
473
|
+
export async function cancelSync(corpId: string, agentId: string, deps: { now: () => number }): Promise<SyncJobView> {
|
|
474
|
+
const now = Math.floor(deps.now() / 1000);
|
|
475
|
+
const job = await readJob(corpId, agentId);
|
|
476
|
+
if (!job)
|
|
477
|
+
return emptyView("idle");
|
|
478
|
+
await writeJob(corpId, agentId, {
|
|
479
|
+
status: "cancelled",
|
|
480
|
+
cursor_text: JSON.stringify(emptyCursor()),
|
|
481
|
+
err_code: null,
|
|
482
|
+
err_detail: null,
|
|
483
|
+
finished_at: now,
|
|
484
|
+
}, now);
|
|
485
|
+
await releaseLock(agentId);
|
|
486
|
+
return toView(await readJob(corpId, agentId));
|
|
487
|
+
}
|
|
@@ -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
|
},
|