@oj-module/cli 0.2.47 → 0.2.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +1 -1
- package/devkit/SKILL.md +23 -4
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +221 -14
- package/devkit/module-dev-and-cdn-playground.md +2 -2
- package/devkit/wecom-daikaifa.md +242 -14
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +80 -0
- package/src/__tests__/templates-tenant-scope.test.ts +7 -2
- package/src/build.ts +50 -0
- package/src/init.ts +1 -1
- package/src/market/__tests__/bootstrap.test.ts +1 -1
- package/src/market/__tests__/setup.test.ts +7 -7
- package/src/market/bootstrap.ts +1 -1
- package/templates/README.md +1 -1
- package/templates/init.include.yaml +12 -0
- package/templates/openapi.json +2863 -549
- package/templates/src/_platform/api/_shared/service/domain/contacts.ts +7 -0
- package/templates/src/_platform/api/_shared/service/domain/org-read.ts +451 -0
- package/templates/src/_platform/api/_shared/service/domain/org.ts +566 -0
- package/templates/src/_platform/api/_shared/service/domain/users.ts +140 -2
- package/templates/src/_platform/api/_shared/service/infra/query.ts +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +1 -1
- package/templates/src/_platform/api/manifest.yaml +3 -0
- package/templates/src/_platform/api/migrations/0016__org.mysql.sql +41 -0
- package/templates/src/_platform/api/migrations/0016__org.pg.sql +40 -0
- package/templates/src/_platform/api/migrations/0016__org.sql +62 -0
- package/templates/src/_platform/api/org/departments/api.ts +51 -0
- package/templates/src/_platform/api/org/is-member/api.ts +33 -0
- package/templates/src/_platform/api/org/members/api.ts +55 -0
- package/templates/src/_platform/api/org/picker/api.ts +59 -0
- package/templates/src/_platform/api/org/resolve/api.ts +38 -0
- package/templates/src/_platform/api/org/tree/api.ts +37 -0
- package/templates/src/_platform/api/org/user/api.ts +46 -0
- package/templates/src/_platform/api/schema.yaml +48 -0
- package/templates/src/_platform/api/seed.mysql.sql +10 -5
- package/templates/src/_platform/api/seed.pg.sql +10 -5
- package/templates/src/_platform/api/seed.sql +13 -5
- package/templates/src/_platform/web/__client/api.schemas.ts +126 -0
- package/templates/src/_platform/web/__client/api.ts +170 -1
- package/templates/src/auth/api/_shared/ldap-login.ts +2 -0
- package/templates/src/auth/api/_shared/session.ts +5 -1
- package/templates/src/auth/api/_shared/wecom/admin.ts +3 -1
- package/templates/src/auth/api/_shared/wecom/client.ts +280 -32
- package/templates/src/auth/api/_shared/wecom/config.ts +12 -1
- package/templates/src/auth/api/_shared/wecom/contacts.ts +263 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +17 -7
- package/templates/src/auth/api/_shared/wecom/events.ts +111 -26
- package/templates/src/auth/api/_shared/wecom/login.ts +188 -5
- package/templates/src/auth/api/_shared/wecom/payload.ts +116 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +65 -0
- package/templates/src/auth/api/_shared/wecom/sync.ts +501 -0
- package/templates/src/auth/api/manifest.yaml +3 -0
- package/templates/src/auth/api/migrations/0002__org_sync.mysql.sql +28 -0
- package/templates/src/auth/api/migrations/0002__org_sync.pg.sql +27 -0
- package/templates/src/auth/api/migrations/0002__org_sync.sql +41 -0
- package/templates/src/auth/api/schema.yaml +31 -0
- package/templates/src/auth/api/wecom/admin/api.ts +3 -1
- package/templates/src/auth/api/wecom/api.ts +155 -35
- package/templates/src/auth/api/wecom/sync/api.ts +199 -0
- package/templates/src/auth/web/__client/api.schemas.ts +97 -0
- package/templates/src/auth/web/__client/api.ts +73 -1
- package/templates/src/login/web/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- package/templates/src/notification/api/seed.sql +1 -1
- package/templates/src/system/api/departments/api.ts +142 -0
- package/templates/src/system/api/departments/detach/api.ts +34 -0
- package/templates/src/system/api/departments/members/api.ts +142 -0
- package/templates/src/system/api/departments/move/api.ts +39 -0
- package/templates/src/system/api/users/api.ts +45 -5
- package/templates/src/system/api/users/profile/api.ts +44 -0
- package/templates/src/system/web/__client/api.schemas.ts +143 -0
- package/templates/src/system/web/__client/api.ts +293 -1
- package/templates/src/system/web/entry.ts +6 -0
- package/templates/src/system/web/locales/en-US.json +112 -6
- package/templates/src/system/web/locales/zh-CN.json +112 -6
- package/templates/src/system/web/pages/departments/index.tsx +346 -0
- package/templates/src/system/web/pages/departments/sync-bar.tsx +257 -0
- package/templates/src/system/web/pages/departments/tree.ts +66 -0
- package/templates/src/system/web/pages/errors.ts +1 -1
- package/templates/src/system/web/pages/users/index.tsx +49 -7
- package/templates/src/system/web/pages/wecom/index.tsx +14 -0
- package/templates/tests/l1-builder.test.ts +1 -1
- package/templates/tests/org-departments.spec.ts +311 -0
- package/templates/tests/org-read.spec.ts +469 -0
- package/templates/tests/rbac-contacts.spec.ts +13 -3
- package/templates/tests/rbac-seed.spec.ts +11 -0
- package/templates/tests/seed-role-accounts.spec.ts +6 -2
- package/templates/tests/wecom-admin.spec.ts +1 -0
- package/templates/tests/wecom-connector.spec.ts +146 -10
- package/templates/tests/wecom-crypt.spec.ts +12 -3
- package/templates/tests/wecom-endpoints.spec.ts +247 -19
- package/templates/tests/wecom-org-sync.spec.ts +598 -0
- package/templates/tests/wecom-sync.spec.ts +286 -0
- package/vendor/host-versions.json +3 -3
- package/templates/src/auth/api/_shared/wecom/xml.ts +0 -36
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// 企微组织架构同步面:POST /auth/wecom/sync/start · GET /auth/wecom/sync/status · POST /auth/wecom/sync/cancel
|
|
2
|
+
//
|
|
3
|
+
// 三条纪律(方案 §3 D7 / §5.3 / §9.5):
|
|
4
|
+
// 1. **不接受 corpId**:只同步调用方当前租户(企微部署下 tenant_id 就是 corp_id)。
|
|
5
|
+
// 跨租户那套(superadmin 代触发)不在这里——它需要绕过 sql_guard,尚未落地,见计划文档。
|
|
6
|
+
// 2. **必须带租户头**:这三个端点不进 tenant.anonymous_paths,`assertTenantSelf` 再兜一层
|
|
7
|
+
// ——伪造 X-TENANT-ID 时 token 的 sub 在目标租户里查无此人,一步拦掉。
|
|
8
|
+
// 3. 门禁:`auth.wecom.sync`(admin/superadmin 天然通过;可绑给自定义角色)。
|
|
9
|
+
//
|
|
10
|
+
// 限速只加在 start/cancel 上(`orgsync:<uid>` 10 次/分):status 是纯本地读取、不打上游,
|
|
11
|
+
// 而进度条要 1~2 秒轮询一次——给它套限速等于把页面焊死。
|
|
12
|
+
//
|
|
13
|
+
// 10 次/分是跟单片预算(sync.ts DEFAULT_BUDGET_MS = 12 秒)配平的:一片打满 12 秒,
|
|
14
|
+
// 串行驱动天然只有 5 次/分,10 次是「不挡住正常推进」的余量;再低(初版 5 次)会在
|
|
15
|
+
// 第 6 片直接 429,大企业同步永远走不完。要动其中一个数,两个一起看。
|
|
16
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
17
|
+
import { assertPermission, assertTenantSelf } from "#/_platform/_shared/service/infra/access";
|
|
18
|
+
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
19
|
+
import { rateLimit } from "#/_platform/_shared/service/infra/rate";
|
|
20
|
+
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
21
|
+
import { readWecomConfig } from "../../_shared/wecom/config";
|
|
22
|
+
import { findProfile, probeProfile, runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
23
|
+
import { attachProfile, cancelSync, emptyView, startSync, syncStatus } from "../../_shared/wecom/sync";
|
|
24
|
+
|
|
25
|
+
function currentTenant(): string {
|
|
26
|
+
const t = (http as unknown as { tenantId?: unknown }).tenantId;
|
|
27
|
+
return typeof t === "string" ? t.trim() : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function currentUid(): number {
|
|
31
|
+
return Number(http.user?.id ?? 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATS = {
|
|
35
|
+
type: "object",
|
|
36
|
+
required: ["departments", "members", "skipped", "reconciled"],
|
|
37
|
+
properties: {
|
|
38
|
+
departments: { type: "number" },
|
|
39
|
+
members: { type: "number" },
|
|
40
|
+
skipped: { type: "number" },
|
|
41
|
+
reconciled: { type: "boolean" },
|
|
42
|
+
},
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const VIEW = {
|
|
46
|
+
type: "object",
|
|
47
|
+
required: ["connected", "authMode", "profileStatus", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
48
|
+
properties: {
|
|
49
|
+
// 授权事实(页面据此决定「整条隐藏 / 成员授权卡片 / 冻结黄条 / 正常同步栏」)
|
|
50
|
+
connected: { type: "boolean" },
|
|
51
|
+
authMode: { type: "number" },
|
|
52
|
+
profileStatus: { type: ["string", "null"] },
|
|
53
|
+
status: { type: "string", enum: ["idle", "running", "done", "failed", "cancelled"] },
|
|
54
|
+
phase: { type: "string", enum: ["departments", "members", "reconcile"] },
|
|
55
|
+
stats: STATS,
|
|
56
|
+
progress: {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["done", "total"],
|
|
59
|
+
properties: { done: { type: "number" }, total: { type: "number" } },
|
|
60
|
+
},
|
|
61
|
+
// 脱敏:只有错码与「哪一环、哪个部门、什么时候」,没有异常原文(原文可能夹带 token)
|
|
62
|
+
errCode: { type: ["string", "null"] },
|
|
63
|
+
errDetail: {
|
|
64
|
+
type: ["object", "null"],
|
|
65
|
+
properties: {
|
|
66
|
+
errcode: { type: "number" },
|
|
67
|
+
phase: { type: "string", enum: ["departments", "members", "reconcile"] },
|
|
68
|
+
deptId: { type: "string" },
|
|
69
|
+
at: { type: "number" },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
startedAt: { type: "number" },
|
|
73
|
+
finishedAt: { type: "number" },
|
|
74
|
+
updateTime: { type: "number" },
|
|
75
|
+
},
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
function agentOf(raw: unknown): string | null {
|
|
79
|
+
const s = String(raw ?? "").trim();
|
|
80
|
+
return s.length > 0 ? s : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析当前租户的授权档案(企业代码 = 租户;多应用时必须带 agent)。
|
|
85
|
+
*
|
|
86
|
+
* 先 probe 再 findProfile:探针负责把「没装 / 已取消授权」翻译成人话(`findProfile` 为防
|
|
87
|
+
* 枚举把两者统一成「企业代码无效或应用未开通」,那句文案摆在自家组织架构页上会让人以为
|
|
88
|
+
* 是自己填错了),findProfile 负责解密出 permanent_code。
|
|
89
|
+
*/
|
|
90
|
+
async function profileOf(tenantId: string, agent: unknown) {
|
|
91
|
+
const probe = await probeProfile(tenantId, agentOf(agent));
|
|
92
|
+
if (!probe) {
|
|
93
|
+
throw Object.assign(new Error("本站尚未开通企业微信:没有可同步的授权记录"), { status: 400 });
|
|
94
|
+
}
|
|
95
|
+
if (probe.status !== "active") {
|
|
96
|
+
throw Object.assign(new Error("该企业已取消授权或应用已停用:组织架构同步已冻结,请联系服务商重新开通"), { status: 400 });
|
|
97
|
+
}
|
|
98
|
+
const cfg = readWecomConfig();
|
|
99
|
+
const profile = await findProfile(tenantId, probe.agentId, runtimeStoreDeps(cfg));
|
|
100
|
+
return { profile, probe, cfg };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const start = {
|
|
104
|
+
/**
|
|
105
|
+
* 开始 / 继续一次同步:**每调一次推一片**,直到 status 变成 done/failed。
|
|
106
|
+
* 可续跑是这套分片设计的全部意义(isolate 无定时器、路由有超时),所以不是「必须从零开始」。
|
|
107
|
+
*/
|
|
108
|
+
async post() {
|
|
109
|
+
await respond(async () => {
|
|
110
|
+
await assertPermission("auth.wecom.sync");
|
|
111
|
+
await assertTenantSelf();
|
|
112
|
+
const tenantId = currentTenant();
|
|
113
|
+
if (!tenantId)
|
|
114
|
+
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
115
|
+
await rateLimit(`orgsync:${currentUid()}`, 10, 60_000);
|
|
116
|
+
|
|
117
|
+
const body = (http.body ?? {}) as { agent?: unknown };
|
|
118
|
+
const { profile, probe, cfg } = await profileOf(tenantId, body.agent);
|
|
119
|
+
const view = await startSync(
|
|
120
|
+
{ corpId: profile.corpId, agentId: profile.agentId, secret: profile.permanentCode, authMode: profile.authMode },
|
|
121
|
+
{ client: createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }), now: () => Date.now() },
|
|
122
|
+
);
|
|
123
|
+
// 审计落在**本租户**(同步只动自己租户的数据):企业管理员在自己的日志里就能看到是谁同步的
|
|
124
|
+
await recordOp({
|
|
125
|
+
action: "wecom.org.sync",
|
|
126
|
+
module: "auth",
|
|
127
|
+
target: profile.corpId,
|
|
128
|
+
detail: { agentId: profile.agentId, status: view.status, phase: view.phase, stats: view.stats, baseUrl: cfg.baseUrl },
|
|
129
|
+
});
|
|
130
|
+
return attachProfile(view, probe);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
start.post.schema = {
|
|
135
|
+
body: {
|
|
136
|
+
type: "object",
|
|
137
|
+
properties: { agent: { type: "string" } },
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
start.post.response = VIEW;
|
|
141
|
+
|
|
142
|
+
const status = {
|
|
143
|
+
// 进度轮询:只读 wecom_sync_job + 一次授权探针,不打上游、不占限速额度。
|
|
144
|
+
// 「本站没装企微」在这里是**正常返回**(connected:false),不是 400——页面整条状态栏据此隐藏。
|
|
145
|
+
async get() {
|
|
146
|
+
await respond(async () => {
|
|
147
|
+
await assertPermission("auth.wecom.sync");
|
|
148
|
+
await assertTenantSelf();
|
|
149
|
+
const tenantId = currentTenant();
|
|
150
|
+
if (!tenantId)
|
|
151
|
+
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
152
|
+
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent));
|
|
153
|
+
if (!probe)
|
|
154
|
+
return attachProfile(emptyView("idle"), null);
|
|
155
|
+
return attachProfile(await syncStatus(tenantId, probe.agentId), probe);
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
status.get.schema = {
|
|
160
|
+
query: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: { agent: { type: "string" } },
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
status.get.response = VIEW;
|
|
166
|
+
|
|
167
|
+
const cancel = {
|
|
168
|
+
// 取消:清游标、置 cancelled。**不回滚**已写入的组织数据——同步幂等,重跑只是覆盖
|
|
169
|
+
async post() {
|
|
170
|
+
await respond(async () => {
|
|
171
|
+
await assertPermission("auth.wecom.sync");
|
|
172
|
+
await assertTenantSelf();
|
|
173
|
+
const tenantId = currentTenant();
|
|
174
|
+
if (!tenantId)
|
|
175
|
+
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
176
|
+
await rateLimit(`orgsync:${currentUid()}`, 10, 60_000);
|
|
177
|
+
|
|
178
|
+
const { profile, probe } = await profileOf(tenantId, (http.body as Record<string, unknown> | undefined)?.agent);
|
|
179
|
+
const view = await cancelSync(profile.corpId, profile.agentId, { now: () => Date.now() });
|
|
180
|
+
await recordOp({
|
|
181
|
+
action: "wecom.org.sync.cancel",
|
|
182
|
+
module: "auth",
|
|
183
|
+
target: profile.corpId,
|
|
184
|
+
detail: { agentId: profile.agentId, phase: view.phase, stats: view.stats },
|
|
185
|
+
level: 4,
|
|
186
|
+
});
|
|
187
|
+
return attachProfile(view, probe);
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
cancel.post.schema = {
|
|
192
|
+
body: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: { agent: { type: "string" } },
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
cancel.post.response = VIEW;
|
|
198
|
+
|
|
199
|
+
export default { start, status, cancel };
|
|
@@ -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
|
+
"auth_mode": z.number(),
|
|
22
23
|
"installed_at": z.number(),
|
|
23
24
|
"update_time": z.number()
|
|
24
25
|
})),
|
|
@@ -44,6 +45,102 @@ export const schemas = {
|
|
|
44
45
|
"authWecomTicketPost": {
|
|
45
46
|
body: z.object({
|
|
46
47
|
"ticket": z.string().min(32).max(128)
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
"authWecomSyncStartPost": {
|
|
51
|
+
body: z.object({
|
|
52
|
+
"agent": z.string().optional()
|
|
53
|
+
}),
|
|
54
|
+
data: z.object({
|
|
55
|
+
"connected": z.boolean(),
|
|
56
|
+
"authMode": z.number(),
|
|
57
|
+
"profileStatus": z.string().nullable(),
|
|
58
|
+
"status": z.enum(["idle", "running", "done", "failed", "cancelled"]),
|
|
59
|
+
"phase": z.enum(["departments", "members", "reconcile"]),
|
|
60
|
+
"stats": z.object({
|
|
61
|
+
"departments": z.number(),
|
|
62
|
+
"members": z.number(),
|
|
63
|
+
"skipped": z.number(),
|
|
64
|
+
"reconciled": z.boolean()
|
|
65
|
+
}),
|
|
66
|
+
"progress": z.object({
|
|
67
|
+
"done": z.number(),
|
|
68
|
+
"total": z.number()
|
|
69
|
+
}),
|
|
70
|
+
"errCode": z.string().nullable(),
|
|
71
|
+
"errDetail": z.object({
|
|
72
|
+
"errcode": z.number().optional(),
|
|
73
|
+
"phase": z.enum(["departments", "members", "reconcile"]).optional(),
|
|
74
|
+
"deptId": z.string().optional(),
|
|
75
|
+
"at": z.number().optional()
|
|
76
|
+
}).nullable(),
|
|
77
|
+
"startedAt": z.number(),
|
|
78
|
+
"finishedAt": z.number(),
|
|
79
|
+
"updateTime": z.number()
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
"authWecomSyncStatusGet": {
|
|
83
|
+
query: z.object({
|
|
84
|
+
"agent": z.string().optional()
|
|
85
|
+
}),
|
|
86
|
+
data: z.object({
|
|
87
|
+
"connected": z.boolean(),
|
|
88
|
+
"authMode": z.number(),
|
|
89
|
+
"profileStatus": z.string().nullable(),
|
|
90
|
+
"status": z.enum(["idle", "running", "done", "failed", "cancelled"]),
|
|
91
|
+
"phase": z.enum(["departments", "members", "reconcile"]),
|
|
92
|
+
"stats": z.object({
|
|
93
|
+
"departments": z.number(),
|
|
94
|
+
"members": z.number(),
|
|
95
|
+
"skipped": z.number(),
|
|
96
|
+
"reconciled": z.boolean()
|
|
97
|
+
}),
|
|
98
|
+
"progress": z.object({
|
|
99
|
+
"done": z.number(),
|
|
100
|
+
"total": z.number()
|
|
101
|
+
}),
|
|
102
|
+
"errCode": z.string().nullable(),
|
|
103
|
+
"errDetail": z.object({
|
|
104
|
+
"errcode": z.number().optional(),
|
|
105
|
+
"phase": z.enum(["departments", "members", "reconcile"]).optional(),
|
|
106
|
+
"deptId": z.string().optional(),
|
|
107
|
+
"at": z.number().optional()
|
|
108
|
+
}).nullable(),
|
|
109
|
+
"startedAt": z.number(),
|
|
110
|
+
"finishedAt": z.number(),
|
|
111
|
+
"updateTime": z.number()
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
"authWecomSyncCancelPost": {
|
|
115
|
+
body: z.object({
|
|
116
|
+
"agent": z.string().optional()
|
|
117
|
+
}),
|
|
118
|
+
data: z.object({
|
|
119
|
+
"connected": z.boolean(),
|
|
120
|
+
"authMode": z.number(),
|
|
121
|
+
"profileStatus": z.string().nullable(),
|
|
122
|
+
"status": z.enum(["idle", "running", "done", "failed", "cancelled"]),
|
|
123
|
+
"phase": z.enum(["departments", "members", "reconcile"]),
|
|
124
|
+
"stats": z.object({
|
|
125
|
+
"departments": z.number(),
|
|
126
|
+
"members": z.number(),
|
|
127
|
+
"skipped": z.number(),
|
|
128
|
+
"reconciled": z.boolean()
|
|
129
|
+
}),
|
|
130
|
+
"progress": z.object({
|
|
131
|
+
"done": z.number(),
|
|
132
|
+
"total": z.number()
|
|
133
|
+
}),
|
|
134
|
+
"errCode": z.string().nullable(),
|
|
135
|
+
"errDetail": z.object({
|
|
136
|
+
"errcode": z.number().optional(),
|
|
137
|
+
"phase": z.enum(["departments", "members", "reconcile"]).optional(),
|
|
138
|
+
"deptId": z.string().optional(),
|
|
139
|
+
"at": z.number().optional()
|
|
140
|
+
}).nullable(),
|
|
141
|
+
"startedAt": z.number(),
|
|
142
|
+
"finishedAt": z.number(),
|
|
143
|
+
"updateTime": z.number()
|
|
47
144
|
}),
|
|
48
145
|
},
|
|
49
146
|
};
|
|
@@ -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 };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -287,3 +287,75 @@ export async function authWecomEventPost(): Promise<unknown> {
|
|
|
287
287
|
throw await toApiError(e);
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
|
+
|
|
291
|
+
export type AuthWecomSyncStartPostBody = z.input<(typeof schemas)["authWecomSyncStartPost"]["body"]>;
|
|
292
|
+
export type AuthWecomSyncStartPostData = z.infer<(typeof schemas)["authWecomSyncStartPost"]["data"]>;
|
|
293
|
+
|
|
294
|
+
export async function authWecomSyncStartPost(body: AuthWecomSyncStartPostBody): Promise<AuthWecomSyncStartPostData> {
|
|
295
|
+
const client = ensureReq();
|
|
296
|
+
try {
|
|
297
|
+
const env = await client.post(`auth/wecom/sync/start`, { json: body }).json<OjEnvelope<AuthWecomSyncStartPostData>>();
|
|
298
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
299
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
300
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
301
|
+
const data = env.data as AuthWecomSyncStartPostData;
|
|
302
|
+
if (import.meta.env.DEV) {
|
|
303
|
+
const { schemas } = await import("./api.schemas");
|
|
304
|
+
const r = schemas["authWecomSyncStartPost"].data.safeParse(data);
|
|
305
|
+
if (!r.success)
|
|
306
|
+
throw new ContractApiError(-1, `[契约违例] authWecomSyncStartPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
307
|
+
}
|
|
308
|
+
return data;
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
throw await toApiError(e);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type AuthWecomSyncStatusGetQuery = z.input<(typeof schemas)["authWecomSyncStatusGet"]["query"]>;
|
|
316
|
+
export type AuthWecomSyncStatusGetData = z.infer<(typeof schemas)["authWecomSyncStatusGet"]["data"]>;
|
|
317
|
+
|
|
318
|
+
export async function authWecomSyncStatusGet(query: AuthWecomSyncStatusGetQuery): Promise<AuthWecomSyncStatusGetData> {
|
|
319
|
+
const client = ensureReq();
|
|
320
|
+
try {
|
|
321
|
+
const env = await client.get(`auth/wecom/sync/status`, { searchParams: query as Record<string, string | number | boolean> }).json<OjEnvelope<AuthWecomSyncStatusGetData>>();
|
|
322
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
323
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
324
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
325
|
+
const data = env.data as AuthWecomSyncStatusGetData;
|
|
326
|
+
if (import.meta.env.DEV) {
|
|
327
|
+
const { schemas } = await import("./api.schemas");
|
|
328
|
+
const r = schemas["authWecomSyncStatusGet"].data.safeParse(data);
|
|
329
|
+
if (!r.success)
|
|
330
|
+
throw new ContractApiError(-1, `[契约违例] authWecomSyncStatusGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
331
|
+
}
|
|
332
|
+
return data;
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
throw await toApiError(e);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export type AuthWecomSyncCancelPostBody = z.input<(typeof schemas)["authWecomSyncCancelPost"]["body"]>;
|
|
340
|
+
export type AuthWecomSyncCancelPostData = z.infer<(typeof schemas)["authWecomSyncCancelPost"]["data"]>;
|
|
341
|
+
|
|
342
|
+
export async function authWecomSyncCancelPost(body: AuthWecomSyncCancelPostBody): Promise<AuthWecomSyncCancelPostData> {
|
|
343
|
+
const client = ensureReq();
|
|
344
|
+
try {
|
|
345
|
+
const env = await client.post(`auth/wecom/sync/cancel`, { json: body }).json<OjEnvelope<AuthWecomSyncCancelPostData>>();
|
|
346
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
347
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
348
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
349
|
+
const data = env.data as AuthWecomSyncCancelPostData;
|
|
350
|
+
if (import.meta.env.DEV) {
|
|
351
|
+
const { schemas } = await import("./api.schemas");
|
|
352
|
+
const r = schemas["authWecomSyncCancelPost"].data.safeParse(data);
|
|
353
|
+
if (!r.success)
|
|
354
|
+
throw new ContractApiError(-1, `[契约违例] authWecomSyncCancelPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
355
|
+
}
|
|
356
|
+
return data;
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
throw await toApiError(e);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"wecom": "WeCom",
|
|
32
32
|
"wecom.action": "Sign in with WeCom",
|
|
33
33
|
"wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
|
|
34
|
+
"wecom.agent": "App ID (agentid)",
|
|
35
|
+
"wecom.agentHint": "Leave empty if the corp has a single app; otherwise use the agentid provided by your admin",
|
|
34
36
|
"wecom.exchanging": "Completing WeCom sign-in…"
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -66,8 +66,13 @@ export default function LoginPage() {
|
|
|
66
66
|
const current = useTenantStore.getState().tenant;
|
|
67
67
|
return current === DEFAULT_TENANT ? "" : current;
|
|
68
68
|
});
|
|
69
|
-
//
|
|
70
|
-
|
|
69
|
+
// 企微深链入口:应用「主页」配成 /login?corp=<corpId>&agent=<agentId>,
|
|
70
|
+
// 成员从企微内点开即带参到达——据此预填企业代码/应用,并由下方 effect 自动发起授权。
|
|
71
|
+
const urlCorp = new URLSearchParams(search).get("corp")?.trim() ?? "";
|
|
72
|
+
const urlAgent = new URLSearchParams(search).get("agent")?.trim() ?? "";
|
|
73
|
+
// 企微 tab 的企业代码(优先用 URL 深链带的,其次已持久化的值,单租户的 default 不预填)
|
|
74
|
+
const [corp, setCorp] = useState(() => urlCorp || tenant);
|
|
75
|
+
const [agent, setAgent] = useState(() => urlAgent);
|
|
71
76
|
|
|
72
77
|
// 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
|
|
73
78
|
const [landing] = useState(() => parseWecomLanding(search));
|
|
@@ -110,6 +115,23 @@ export default function LoginPage() {
|
|
|
110
115
|
})();
|
|
111
116
|
}, [landing, login, navigate, search]);
|
|
112
117
|
|
|
118
|
+
// 企微深链自动入口:带 ?corp= 到达(且非回跳落地 / 非出错)且企微方式可用时,
|
|
119
|
+
// 直接整页发起授权——「点开即登录」,无需手动点「企业微信」tab。
|
|
120
|
+
const autoFiredRef = useRef(false);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const c = new URLSearchParams(search).get("corp")?.trim() ?? "";
|
|
123
|
+
// 企微内(wxwork UA)从「应用主页」点开即自动发起授权;带 ?corp= 的深链同样自动发起。
|
|
124
|
+
const inWecom = typeof navigator !== "undefined" && /wxwork/i.test(navigator.userAgent);
|
|
125
|
+
if ((!c && !inWecom) || landing || autoFiredRef.current)
|
|
126
|
+
return;
|
|
127
|
+
if (new URLSearchParams(search).get("error"))
|
|
128
|
+
return;
|
|
129
|
+
if (!methods.includes("wecom"))
|
|
130
|
+
return;
|
|
131
|
+
autoFiredRef.current = true;
|
|
132
|
+
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
|
|
133
|
+
}, [landing, methods, search, corp, agent]);
|
|
134
|
+
|
|
113
135
|
async function onFinish(values: LoginFormValues, method: LoginMethod) {
|
|
114
136
|
setLoading(true);
|
|
115
137
|
setError(undefined);
|
|
@@ -144,7 +166,7 @@ export default function LoginPage() {
|
|
|
144
166
|
// 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
|
|
145
167
|
// SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
|
|
146
168
|
// 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
|
|
147
|
-
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
|
|
169
|
+
window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp, agent)}`);
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
const showTabs = methods.length > 1;
|
|
@@ -180,6 +202,7 @@ export default function LoginPage() {
|
|
|
180
202
|
// 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
|
|
181
203
|
// 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
|
|
182
204
|
const wecomCorpId = "wecom-corp";
|
|
205
|
+
const wecomAgentId = "wecom-agent";
|
|
183
206
|
const wecomPanel = (
|
|
184
207
|
<Form layout="vertical" onFinish={wecomLogin}>
|
|
185
208
|
<Form.Item
|
|
@@ -195,6 +218,19 @@ export default function LoginPage() {
|
|
|
195
218
|
onChange={e => setCorp(e.target.value)}
|
|
196
219
|
/>
|
|
197
220
|
</Form.Item>
|
|
221
|
+
<Form.Item
|
|
222
|
+
label={t("login:method.wecom.agent")}
|
|
223
|
+
htmlFor={wecomAgentId}
|
|
224
|
+
extra={t("login:method.wecom.agentHint")}
|
|
225
|
+
>
|
|
226
|
+
<Input
|
|
227
|
+
id={wecomAgentId}
|
|
228
|
+
autoComplete="off"
|
|
229
|
+
placeholder="1000..."
|
|
230
|
+
value={agent}
|
|
231
|
+
onChange={e => setAgent(e.target.value)}
|
|
232
|
+
/>
|
|
233
|
+
</Form.Item>
|
|
198
234
|
<Button type="primary" htmlType="submit" block>
|
|
199
235
|
{t("login:method.wecom.action")}
|
|
200
236
|
</Button>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
-- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
|
|
2
2
|
-- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
|
|
3
3
|
-- 按分号朴素切分(S006):语句内不得含分号字面量。
|
|
4
|
-
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin /
|
|
4
|
+
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / aA123456 登录开始', '欢迎', NULL, 'notice');
|
|
5
5
|
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, 'https://avatar.vercel.sh/seed-bell.svg?text=B', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
|
|
6
6
|
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, 'https://avatar.vercel.sh/seed-contract.svg?text=C', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
|
|
7
7
|
INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, 'https://avatar.vercel.sh/seed-dev.svg?text=D', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { OrgDeps } from "#/_platform/_shared/service/domain/org";
|
|
2
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
3
|
+
// 管理域端点组 /api/system/departments —— 组织架构 CRUD 基线(方案 A2)。
|
|
4
|
+
// handler 只编排:assertSystemAdmin 守卫 → org 领域服务 → json 信封;归属权红线在 org.ts(D13)。
|
|
5
|
+
//
|
|
6
|
+
// 列表**返回平表而不是树**:oj 的响应契约不支持递归 schema(contract IR 无 $ref),
|
|
7
|
+
// 树由前端按 parentId 组装。管理员要看全量(含 sync_visible=0 与软删壳),故两个开关全开。
|
|
8
|
+
import {
|
|
9
|
+
createDepartment,
|
|
10
|
+
deleteDepartment,
|
|
11
|
+
listDepartments,
|
|
12
|
+
memberCounts,
|
|
13
|
+
|
|
14
|
+
updateDepartment,
|
|
15
|
+
} from "#/_platform/_shared/service/domain/org";
|
|
16
|
+
import { assertSystemAdmin } from "#/_platform/_shared/service/infra/access";
|
|
17
|
+
|
|
18
|
+
/** 秒级 epoch(与 sys_oplog / wecom store 同一口径) */
|
|
19
|
+
const orgDeps: OrgDeps = { now: () => Math.floor(Date.now() / 1000) };
|
|
20
|
+
|
|
21
|
+
// GET /api/system/departments —— 部门平表(含成员计数),管理页树的数据源。
|
|
22
|
+
async function get() {
|
|
23
|
+
await respond(async () => {
|
|
24
|
+
await assertSystemAdmin();
|
|
25
|
+
const [depts, counts] = await Promise.all([
|
|
26
|
+
listDepartments({ includeDeleted: true, includeHidden: true }),
|
|
27
|
+
memberCounts(),
|
|
28
|
+
]);
|
|
29
|
+
return depts.map(d => ({ ...d, memberCount: counts.get(d.id) ?? 0 }));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
get.response = {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: {
|
|
35
|
+
type: "object",
|
|
36
|
+
required: ["id", "origin", "originId", "parentId", "name", "orderNo", "syncVisible", "deleted", "memberCount"],
|
|
37
|
+
properties: {
|
|
38
|
+
id: { type: "number" },
|
|
39
|
+
// 归属权标识:local = 本站可 CRUD;其余 = 对应适配器管辖(D13)
|
|
40
|
+
origin: { type: "string" },
|
|
41
|
+
originId: { type: "string" },
|
|
42
|
+
parentId: { type: ["number", "null"] },
|
|
43
|
+
name: { type: ["string", "null"] },
|
|
44
|
+
nameEn: { type: ["string", "null"] },
|
|
45
|
+
orderNo: { type: "number" },
|
|
46
|
+
syncVisible: { type: "boolean" },
|
|
47
|
+
deleted: { type: "boolean" },
|
|
48
|
+
memberCount: { type: "number" },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// POST /api/system/departments —— 建部门(origin 恒 local)。
|
|
54
|
+
async function post() {
|
|
55
|
+
await respond(async () => {
|
|
56
|
+
await assertSystemAdmin();
|
|
57
|
+
const b = (http.body || {}) as { name?: string, parentId?: number | null, nameEn?: string | null, orderNo?: number };
|
|
58
|
+
return { id: await createDepartment({
|
|
59
|
+
name: String(b.name ?? ""),
|
|
60
|
+
parentId: b.parentId ?? null,
|
|
61
|
+
nameEn: b.nameEn ?? null,
|
|
62
|
+
orderNo: b.orderNo,
|
|
63
|
+
}, orgDeps) };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
post.schema = {
|
|
67
|
+
body: {
|
|
68
|
+
type: "object",
|
|
69
|
+
required: ["name"],
|
|
70
|
+
properties: {
|
|
71
|
+
name: { type: "string", minLength: 1 },
|
|
72
|
+
// 不给 parentId 即建在根下
|
|
73
|
+
parentId: { type: ["number", "null"] },
|
|
74
|
+
nameEn: { type: ["string", "null"] },
|
|
75
|
+
orderNo: { type: "number" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
post.response = {
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["id"],
|
|
82
|
+
properties: { id: { type: "number" } },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// PUT /api/system/departments?id= —— 改名/排序。部门归属 origin='local' 才放行(D13)。
|
|
86
|
+
async function put() {
|
|
87
|
+
await respond(async () => {
|
|
88
|
+
await assertSystemAdmin();
|
|
89
|
+
const q = http.query as Record<string, unknown>;
|
|
90
|
+
const b = (http.body || {}) as { name?: string, nameEn?: string | null, orderNo?: number };
|
|
91
|
+
await updateDepartment(Number(q.id), {
|
|
92
|
+
name: b.name,
|
|
93
|
+
nameEn: b.nameEn,
|
|
94
|
+
orderNo: b.orderNo,
|
|
95
|
+
}, orgDeps);
|
|
96
|
+
return { ok: true };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
put.schema = {
|
|
100
|
+
query: {
|
|
101
|
+
type: "object",
|
|
102
|
+
required: ["id"],
|
|
103
|
+
properties: { id: { type: "number" } },
|
|
104
|
+
},
|
|
105
|
+
body: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
name: { type: "string", minLength: 1 },
|
|
109
|
+
nameEn: { type: ["string", "null"] },
|
|
110
|
+
orderNo: { type: "number" },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
put.response = {
|
|
115
|
+
type: "object",
|
|
116
|
+
required: ["ok"],
|
|
117
|
+
properties: { ok: { type: "boolean" } },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// DELETE /api/system/departments?id= —— 删部门(有子部门即拒;关联成员行一并清)。
|
|
121
|
+
async function del() {
|
|
122
|
+
await respond(async () => {
|
|
123
|
+
await assertSystemAdmin();
|
|
124
|
+
const q = http.query as Record<string, unknown>;
|
|
125
|
+
await deleteDepartment(Number(q.id));
|
|
126
|
+
return { ok: true };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
del.schema = {
|
|
130
|
+
query: {
|
|
131
|
+
type: "object",
|
|
132
|
+
required: ["id"],
|
|
133
|
+
properties: { id: { type: "number" } },
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
del.response = {
|
|
137
|
+
type: "object",
|
|
138
|
+
required: ["ok"],
|
|
139
|
+
properties: { ok: { type: "boolean" } },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export default { get, post, put, del };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { OrgDeps } from "#/_platform/_shared/service/domain/org";
|
|
2
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
3
|
+
import { detachToLocal } from "#/_platform/_shared/service/domain/org";
|
|
4
|
+
// POST /api/system/departments/detach?id= —— 「转为本地维护」(D14 保底出口)。
|
|
5
|
+
//
|
|
6
|
+
// 把一行脱离适配器管辖(origin → local):此后改名/移动/删除全开,且后续同步轮次
|
|
7
|
+
// 不再改写它。这是「外部源只给了残缺数据(如企微未授权部门名)」时唯一的出路。
|
|
8
|
+
// 反向操作不提供:转回去后下一次同步立刻按源口径改写,语义不可控。
|
|
9
|
+
import { assertSystemAdmin } from "#/_platform/_shared/service/infra/access";
|
|
10
|
+
|
|
11
|
+
const orgDeps: OrgDeps = { now: () => Math.floor(Date.now() / 1000) };
|
|
12
|
+
|
|
13
|
+
async function post() {
|
|
14
|
+
await respond(async () => {
|
|
15
|
+
await assertSystemAdmin();
|
|
16
|
+
const q = http.query as Record<string, unknown>;
|
|
17
|
+
await detachToLocal(Number(q.id), orgDeps);
|
|
18
|
+
return { ok: true };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
post.schema = {
|
|
22
|
+
query: {
|
|
23
|
+
type: "object",
|
|
24
|
+
required: ["id"],
|
|
25
|
+
properties: { id: { type: "number" } },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
post.response = {
|
|
29
|
+
type: "object",
|
|
30
|
+
required: ["ok"],
|
|
31
|
+
properties: { ok: { type: "boolean" } },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default { post };
|