@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WecomConfig } from "../_shared/wecom/config";
|
|
2
|
+
import type { CallbackFields } from "../_shared/wecom/payload";
|
|
2
3
|
import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
3
4
|
// GET /api/auth/wecom/login?corp=&agent=&env= —— 发起企微授权(企微内静默 / 浏览器扫码)
|
|
4
5
|
// GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
|
|
@@ -18,7 +19,7 @@ import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } fro
|
|
|
18
19
|
import { createSession } from "../_shared/session";
|
|
19
20
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
20
21
|
import { loginRedirectOf, readCallbackConfig, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
|
|
21
|
-
import { unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
22
|
+
import { sha1Hex, unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
22
23
|
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
23
24
|
import {
|
|
24
25
|
assertUserActive,
|
|
@@ -27,11 +28,12 @@ import {
|
|
|
27
28
|
LOGIN_TICKET_TTL_SECS,
|
|
28
29
|
loginLandingUrl,
|
|
29
30
|
putLoginState,
|
|
31
|
+
selfUpdate,
|
|
30
32
|
takeLoginState,
|
|
31
33
|
} from "../_shared/wecom/login";
|
|
34
|
+
import { callbackFields, field } from "../_shared/wecom/payload";
|
|
32
35
|
import { guardReplay, runtimeReplayGate } from "../_shared/wecom/replay";
|
|
33
36
|
import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
|
|
34
|
-
import { xmlField } from "../_shared/wecom/xml";
|
|
35
37
|
|
|
36
38
|
// 模块级禁止 readWecomConfig()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
|
|
37
39
|
// 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
|
|
@@ -58,6 +60,62 @@ function badRequest(message: string): Error {
|
|
|
58
60
|
return Object.assign(new Error(message), { status: 400 });
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 解不开时把「哪一侧配错」记进日志——只回一句 `pkcs7: bad padding` 等于没给线索。
|
|
65
|
+
*
|
|
66
|
+
* 能先验签通过才走到解密,说明后台 Token 与本部署一致;于是解不开只剩两类:
|
|
67
|
+
* · `bad padding` → **钥匙不对**:EncodingAESKey 与后台不一致(后台点过「随机生成」、或抄错/抄漏
|
|
68
|
+
* 一个字符),或这条推送其实来自另一个套件/模板(那把钥匙解不开这份密文)。
|
|
69
|
+
* `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹:改完 config 对不上就说明没生效(没重启/改错文件)。
|
|
70
|
+
* · `receiver mismatch` → **钥匙对、身份不对**。`receiveid` 已按候选试过(见 unwrapAccepting:
|
|
71
|
+
* suite_id + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,或这条推送
|
|
72
|
+
* 既不属本部署套件、receiveid 也不是它自己声称的身份(伪造/转发)。
|
|
73
|
+
*
|
|
74
|
+
* 注意 `envelopeToUser` **不等于** `configuredSuite` 是正常的:数据回调的 `ToUserName` 是 corpid。
|
|
75
|
+
*/
|
|
76
|
+
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: { encodingAesKey: string, suiteId: string }): void {
|
|
77
|
+
log.warn(
|
|
78
|
+
"wecom callback decrypt failed",
|
|
79
|
+
"reason",
|
|
80
|
+
(e as Error).message,
|
|
81
|
+
"envelopeToUser",
|
|
82
|
+
// URL 验证(GET)只有 query 里的 echostr,没有报文信封——那一侧记空串
|
|
83
|
+
envelope ? field(envelope, "ToUserName") : "",
|
|
84
|
+
"configuredSuite",
|
|
85
|
+
cfg.suiteId,
|
|
86
|
+
"aesKeyFp",
|
|
87
|
+
sha1Hex(cfg.encodingAesKey).slice(0, 8),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 按「可能合法的 receiveid」依次尝试解密。
|
|
93
|
+
*
|
|
94
|
+
* receiveid 的含义随场景变(90968 附注):**第三方事件的回调是 suiteid**(suite_ticket / create_auth
|
|
95
|
+
* / cancel_auth),**企业应用(含代开发应用的数据回调)是 corpid**(通讯录变更就走这条)。
|
|
96
|
+
* 我们一个端点吃两种,只认 suiteid 会把所有数据回调判成「解密失败」——而真正的错是「比错了值」。
|
|
97
|
+
*
|
|
98
|
+
* 只对 `receiver mismatch` 换下一个候选:`bad padding` 说明密钥就不对,换谁都解不开,直接抛。
|
|
99
|
+
*/
|
|
100
|
+
function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string[]): string {
|
|
101
|
+
let lastErr: unknown;
|
|
102
|
+
const tried = new Set<string>();
|
|
103
|
+
for (const receiver of candidates) {
|
|
104
|
+
if (!receiver || tried.has(receiver))
|
|
105
|
+
continue;
|
|
106
|
+
tried.add(receiver);
|
|
107
|
+
try {
|
|
108
|
+
return unwrapWecomMessage(encrypted, cfg.encodingAesKey, receiver);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (!/receiver mismatch/.test((e as Error).message))
|
|
112
|
+
throw e;
|
|
113
|
+
lastErr = e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw lastErr ?? new Error("wecom: 没有可比对的 receiveid(suite_id 未配置?)");
|
|
117
|
+
}
|
|
118
|
+
|
|
61
119
|
/**
|
|
62
120
|
* 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
|
|
63
121
|
* 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
|
|
@@ -65,18 +123,20 @@ function badRequest(message: string): Error {
|
|
|
65
123
|
* 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
|
|
66
124
|
* cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
|
|
67
125
|
*/
|
|
68
|
-
async function decryptVerified(encrypted: string): Promise<{ cfg: WecomConfig, plain: string }> {
|
|
126
|
+
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ cfg: WecomConfig, plain: string }> {
|
|
69
127
|
const cfg = readWecomConfig();
|
|
70
128
|
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
71
129
|
if (expected !== query("msg_signature"))
|
|
72
130
|
throw badRequest("回调签名校验失败");
|
|
73
131
|
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
74
132
|
try {
|
|
75
|
-
|
|
133
|
+
// 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
|
|
134
|
+
// 与 receiveid 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
|
|
135
|
+
return { cfg, plain: unwrapAccepting(encrypted, cfg, [cfg.suiteId, field(envelope, "ToUserName")]) };
|
|
76
136
|
}
|
|
77
137
|
catch (e) {
|
|
78
|
-
//
|
|
79
|
-
|
|
138
|
+
// 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
139
|
+
noteDecryptFailure(e, envelope, cfg);
|
|
80
140
|
throw badRequest("回调消息解密失败");
|
|
81
141
|
}
|
|
82
142
|
}
|
|
@@ -135,12 +195,13 @@ const callback = {
|
|
|
135
195
|
throw badRequest("登录状态缺失:请从企业微信重新进入");
|
|
136
196
|
|
|
137
197
|
const deps = runtimeStoreDeps(cfg);
|
|
138
|
-
const
|
|
198
|
+
const cache = runtimeCache();
|
|
199
|
+
const pending = await takeLoginState(state, cache);
|
|
139
200
|
const profile = await findProfile(pending.corpId, pending.agentId, deps);
|
|
140
201
|
// 顺序刚性:先绑租户再碰 users(JIT 建号是租户表写入,守卫 deny 下缺上下文即拒)
|
|
141
202
|
ensureTenant(tenantOfCorp(profile.corpId));
|
|
142
203
|
|
|
143
|
-
const client = createWecomClient({ http: runtimeHttp(), cache
|
|
204
|
+
const client = createWecomClient({ http: runtimeHttp(), cache });
|
|
144
205
|
const userid = await client.userIdByCode(profile.corpId, profile.agentId, profile.permanentCode, code);
|
|
145
206
|
// 名字是惰性取的:只有首次建档才多打一次 user/get(老用户零开销),拿不到就用工号兜底
|
|
146
207
|
const { uid, forbidden } = await ensureWecomUser(profile.corpId, userid, () =>
|
|
@@ -148,6 +209,15 @@ const callback = {
|
|
|
148
209
|
if (forbidden)
|
|
149
210
|
throw Object.assign(new Error("账号已被停用,请联系管理员"), { status: 403 });
|
|
150
211
|
|
|
212
|
+
// 轨一:登录自更新(方案 §6.1)。节流 24h、失败不影响登录;但企微侧已离职/禁用的
|
|
213
|
+
// 账号必须在这里拦下——本站的 is_forbidden 永远以管理员为准,不能被 IdP 反向覆盖。
|
|
214
|
+
const self = await selfUpdate(
|
|
215
|
+
{ uid, userid, corpId: profile.corpId, agentId: profile.agentId, permanentCode: profile.permanentCode },
|
|
216
|
+
{ client, cache, store: deps },
|
|
217
|
+
);
|
|
218
|
+
if (self.blocked)
|
|
219
|
+
throw Object.assign(new Error("账号在企业微信中已被停用或已退出企业,请联系企业管理员"), { status: 403 });
|
|
220
|
+
|
|
151
221
|
// 票据而不是 token 进 URL:token 会留在浏览器历史/Referer/反代日志里
|
|
152
222
|
const ticket = crypto.randomHex(32);
|
|
153
223
|
const tenant = tenantOfCorp(profile.corpId);
|
|
@@ -198,8 +268,9 @@ const event = {
|
|
|
198
268
|
// 不需要 suite_id——因此「配齐四项正确即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。
|
|
199
269
|
// 这正是打破鸡生蛋死锁(plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
200
270
|
//
|
|
201
|
-
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id
|
|
202
|
-
//
|
|
271
|
+
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验)。
|
|
272
|
+
// 加上「应用回调 URL 验证时 receiveid 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,
|
|
273
|
+
// 见下面 try 里的说明(签名是唯一闸门,本端点不落状态)。
|
|
203
274
|
async get() {
|
|
204
275
|
try {
|
|
205
276
|
const cfg = readCallbackConfig();
|
|
@@ -210,11 +281,15 @@ const event = {
|
|
|
210
281
|
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
211
282
|
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
212
283
|
try {
|
|
213
|
-
|
|
284
|
+
// receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
|
|
285
|
+
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL」验证时是 corpid。
|
|
286
|
+
// 放宽是安全的:签名已过(token 只有本部署与企微知道),回包只是把解出的 echostr 原样返回,
|
|
287
|
+
// 不写任何状态;真正的 receiveid 比对在 POST 那条路径上(见 unwrapAccepting)。
|
|
288
|
+
plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, true));
|
|
214
289
|
}
|
|
215
290
|
catch (e) {
|
|
216
291
|
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
217
|
-
|
|
292
|
+
noteDecryptFailure(e, null, { encodingAesKey: cfg.encodingAesKey, suiteId });
|
|
218
293
|
throw badRequest("回调消息解密失败");
|
|
219
294
|
}
|
|
220
295
|
}
|
|
@@ -223,20 +298,27 @@ const event = {
|
|
|
223
298
|
}
|
|
224
299
|
},
|
|
225
300
|
|
|
226
|
-
//
|
|
227
|
-
// reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
301
|
+
// 回调(指令 + 应用数据):验签 → 解密 → 分发(suite_ticket / create_auth / change_auth /
|
|
302
|
+
// cancel_auth / reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
303
|
+
//
|
|
304
|
+
// 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
|
|
305
|
+
// 所以本函数只认字段名,不认格式。
|
|
228
306
|
async post() {
|
|
229
307
|
try {
|
|
230
|
-
const
|
|
231
|
-
const encrypted =
|
|
308
|
+
const envelope = callbackFields(http.body);
|
|
309
|
+
const encrypted = field(envelope, "Encrypt");
|
|
232
310
|
if (!encrypted)
|
|
233
|
-
throw badRequest("回调报文缺少 Encrypt");
|
|
234
|
-
const { cfg, plain } = await decryptVerified(encrypted);
|
|
311
|
+
throw badRequest("回调报文缺少 Encrypt(不是企微回调,或中间件改写了请求体)");
|
|
312
|
+
const { cfg, plain } = await decryptVerified(encrypted, envelope);
|
|
235
313
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
236
314
|
await handleSuiteEvent(plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
237
315
|
plainText("success");
|
|
238
316
|
}
|
|
239
317
|
catch (e) {
|
|
318
|
+
// 企微侧只会显示「应用安装失败,联系服务商」,原因一概不透露——这里是唯一能查到
|
|
319
|
+
// 「哪一环拒了这条事件」的地方(缺这一行,这场失败在运维视角是零信息)。
|
|
320
|
+
const err = e as Error & { status?: number };
|
|
321
|
+
log.error("wecom event failed", "status", err.status ?? 500, "reason", err.message);
|
|
240
322
|
// 失败必须让企微看到非 success:它重推三次,正好覆盖「suite_ticket 未到/上游抖」这类瞬态
|
|
241
323
|
failWith(e);
|
|
242
324
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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');
|