@oj-module/cli 0.2.48 → 0.2.50
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 +9 -7
- package/devkit/SKILL.md +53 -14
- package/devkit/manual.md +203 -3
- package/devkit/wecom-daikaifa.md +100 -1
- package/devkit/wecom-internal-app.md +643 -0
- 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/build.ts +50 -0
- package/src/init.ts +5 -4
- package/templates/config.yaml +28 -6
- package/templates/openapi.json +174 -2
- package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
- package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
- package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
- package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
- package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
- package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
- package/templates/src/auth/api/schema.yaml +2 -0
- package/templates/src/auth/api/wecom/admin/api.ts +112 -11
- package/templates/src/auth/api/wecom/api.ts +151 -59
- package/templates/src/auth/api/wecom/sync/api.ts +21 -5
- package/templates/src/auth/web/__client/api.schemas.ts +22 -1
- package/templates/src/auth/web/__client/api.ts +47 -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/system/web/locales/en-US.json +24 -1
- package/templates/src/system/web/locales/zh-CN.json +24 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/wecom/index.tsx +164 -46
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +38 -8
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +73 -7
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/vendor/host-versions.json +3 -3
|
@@ -179,19 +179,44 @@ afterEach(() => {
|
|
|
179
179
|
|
|
180
180
|
describe("P1-I:GET /auth/wecom/login(发起授权)", () => {
|
|
181
181
|
it("部署常量缺失 → 400 信封并点出缺哪几个 vars(不是 500)", async () => {
|
|
182
|
-
|
|
182
|
+
// 登录只依赖「与来源无关」的两项(落库密钥 + 基址):suite/corp 凭据不进必填集合,
|
|
183
|
+
// 否则自建应用的部署会在读 wecom_suite_id 处直接 400(两种来源就没法共存了)。
|
|
184
|
+
installVars({ wecom_enc_key: "", wecom_base_url: "" });
|
|
183
185
|
request({ query: { corp: "ww_corp_a" } });
|
|
184
186
|
await (await api()).login.get();
|
|
185
187
|
expect(out.status).toBe(400);
|
|
186
|
-
expect(out.body.msg).toMatch(/
|
|
188
|
+
expect(out.body.msg).toMatch(/wecom_enc_key/);
|
|
189
|
+
expect(out.body.msg).toMatch(/wecom_base_url/);
|
|
187
190
|
expect(out.location).toBeNull();
|
|
188
191
|
});
|
|
189
192
|
|
|
190
|
-
|
|
193
|
+
// 代开发模板常只服务一家企业:corp 缺省时从已安装档案自动解析,好让「应用主页」直接填
|
|
194
|
+
// `https://<域名>/login`(企微后台会因 URL 里的 `${corpid}` 占位符字符拒收)。三种情形分开钉。
|
|
195
|
+
it("缺企业代码且一家都没装 → 400,同时给出两种来源的下一步(不猜默认企业)", async () => {
|
|
191
196
|
request({ query: {} });
|
|
192
197
|
await (await api()).login.get();
|
|
193
198
|
expect(out.status).toBe(400);
|
|
194
|
-
expect(out.body.msg).toMatch(
|
|
199
|
+
expect(out.body.msg).toMatch(/尚未完成企业微信接入/);
|
|
200
|
+
// 文案要能分诊:代开发去扫码开通,自建应用去后台点「验证并接入」
|
|
201
|
+
expect(out.body.msg).toMatch(/代开发/);
|
|
202
|
+
expect(out.body.msg).toMatch(/验证并接入/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("缺企业代码但只装了一家 → 302 到该企业的授权地址(省掉手填企业代码)", async () => {
|
|
206
|
+
seedCorp();
|
|
207
|
+
request({ query: {}, headers: { "user-agent": "Mozilla/5.0 Chrome/120" } });
|
|
208
|
+
await (await api()).login.get();
|
|
209
|
+
expect(out.status).toBe(302);
|
|
210
|
+
expect(out.location).toContain("appid=ww_corp_a");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("缺企业代码且装了多家 → 400,要求链接里带 corp 参数区分", async () => {
|
|
214
|
+
seedCorp("ww_corp_a");
|
|
215
|
+
seedCorp("ww_corp_b");
|
|
216
|
+
request({ query: {} });
|
|
217
|
+
await (await api()).login.get();
|
|
218
|
+
expect(out.status).toBe(400);
|
|
219
|
+
expect(out.body.msg).toMatch(/corp/);
|
|
195
220
|
});
|
|
196
221
|
|
|
197
222
|
it("未授权企业 → 400(文案不区分「没装」与「已停用」)", async () => {
|
|
@@ -565,6 +590,42 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
565
590
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
566
591
|
});
|
|
567
592
|
|
|
593
|
+
// 2026-11-01 生产时序:create_auth → 上游 502 → 企微 5 秒后重推 → 被「nonce 重复」挡回。
|
|
594
|
+
// 重推是瞬态故障唯一的恢复路径,让它废在重放守卫上等于把恢复路径拆了。
|
|
595
|
+
it("处理失败的 nonce 会被放回:重推按真实原因重试,不被判成重放", async () => {
|
|
596
|
+
const plain = "<xml><InfoType>suite_ticket</InfoType><SuiteId>other_suite</SuiteId><SuiteTicket>tk_x</SuiteTicket></xml>";
|
|
597
|
+
const encrypted = await seal(plain);
|
|
598
|
+
const query = signedQuery(encrypted, { nonce: "retry_once" });
|
|
599
|
+
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
600
|
+
|
|
601
|
+
request({ query, body });
|
|
602
|
+
await (await api()).event.post();
|
|
603
|
+
expect(out.status).toBe(400);
|
|
604
|
+
expect(out.body.msg).toMatch(/SuiteId/);
|
|
605
|
+
|
|
606
|
+
// 同一条报文重推(企微的语义):仍按真实原因失败,而不是 400「nonce 重复」
|
|
607
|
+
request({ query, body });
|
|
608
|
+
await (await api()).event.post();
|
|
609
|
+
expect(out.body.msg).toMatch(/SuiteId/);
|
|
610
|
+
expect(out.body.msg).not.toMatch(/nonce 重复/);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// 重放闸门的本体:同 nonce 已被占用、而首推还没标「已落库」——此刻重复投递必须挡下
|
|
614
|
+
// (可能是真重放,也可能是首推仍在处理中;两种都不该并发出第二份副作用)。
|
|
615
|
+
it("同 nonce 已被占用且未标已落库 → 400「nonce 重复」(重放闸门本体)", async () => {
|
|
616
|
+
const encrypted = await seal(TICKET_XML);
|
|
617
|
+
const query = signedQuery(encrypted, { nonce: "inflight" });
|
|
618
|
+
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
619
|
+
// 模拟「首推正在处理」:计数已到 1,但没有 applied 标记
|
|
620
|
+
await g.kv.incr("wecom:nonce:inflight");
|
|
621
|
+
|
|
622
|
+
request({ query, body });
|
|
623
|
+
await (await api()).event.post();
|
|
624
|
+
expect(out.status).toBe(400);
|
|
625
|
+
expect(out.body.msg).toMatch(/nonce 重复/);
|
|
626
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
627
|
+
});
|
|
628
|
+
|
|
568
629
|
it("EncodingAESKey 与后台不一致(签名过、解密爆 pkcs7)→ 400,日志点名是哪一侧配错", async () => {
|
|
569
630
|
// 用户实报的原样症状:WARN reason=pkcs7: bad padding。签名先过说明后台 Token 与本部署一致,
|
|
570
631
|
// 所以只回一句 bad padding 等于没给线索——日志必须带「外层 ToUserName / 配置套件 / 实际加载的密钥指纹」。
|
|
@@ -605,19 +666,24 @@ describe("P1-L:GET/POST /auth/wecom/event(企微指令回调)", () => {
|
|
|
605
666
|
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 0 });
|
|
606
667
|
});
|
|
607
668
|
|
|
608
|
-
|
|
669
|
+
// 处理成功后的重推:回 success 而不是 400——企微只在「拿不到 success」时重推,首推若因回包慢
|
|
670
|
+
// 被判超时,重推再被挡成 400,管理员端就会看到「应用安装失败」。副作用仍然只发生一次。
|
|
671
|
+
it("同一条回调连发两次(成功后的重推)→ 第二次幂等回 success,票据只落一次", async () => {
|
|
609
672
|
const encrypted = await seal(TICKET_XML);
|
|
610
673
|
const body = `<xml><Encrypt>${encrypted}</Encrypt></xml>`;
|
|
611
674
|
request({ query: signedQuery(encrypted), body });
|
|
612
675
|
await (await api()).event.post();
|
|
613
676
|
expect(out.status).toBe(200);
|
|
677
|
+
expect(out.text).toBe("success");
|
|
614
678
|
|
|
615
679
|
installJson();
|
|
616
680
|
request({ query: signedQuery(encrypted), body });
|
|
617
681
|
await (await api()).event.post();
|
|
618
|
-
expect(out.status).toBe(
|
|
619
|
-
expect(out.
|
|
682
|
+
expect(out.status).toBe(200);
|
|
683
|
+
expect(out.text).toBe("success");
|
|
684
|
+
// 幂等成功不等于重放执行:库里仍是首推那次的值
|
|
620
685
|
expect(dbo.prepare("SELECT suite_ticket FROM wecom_suite_ticket WHERE suite_id = ?").get(SUITE)).toMatchObject({ suite_ticket: "tk_9" });
|
|
686
|
+
expect(dbo.prepare("SELECT COUNT(*) AS n FROM wecom_suite_ticket").get()).toMatchObject({ n: 1 });
|
|
621
687
|
});
|
|
622
688
|
|
|
623
689
|
it("换一个新 nonce 的同内容回调仍能落(重放闸门没把正常重试误伤)", async () => {
|
|
@@ -184,6 +184,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
|
|
|
184
184
|
agentId: "100001",
|
|
185
185
|
env: "prod",
|
|
186
186
|
corpName: "测试企业",
|
|
187
|
+
credentialKind: "suite",
|
|
187
188
|
permanentCode: "perm_a",
|
|
188
189
|
authMode: 1,
|
|
189
190
|
}, storeDeps());
|
|
@@ -199,6 +200,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
|
|
|
199
200
|
agentId: "100001",
|
|
200
201
|
env: "prod",
|
|
201
202
|
corpName: null,
|
|
203
|
+
credentialKind: "suite",
|
|
202
204
|
permanentCode: "perm_a",
|
|
203
205
|
} as never, storeDeps());
|
|
204
206
|
expect((await findProfile("ww_corp_a", "100001", storeDeps())).authMode).toBe(0);
|
|
@@ -212,6 +214,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
|
|
|
212
214
|
agentId: "100001",
|
|
213
215
|
env: "prod",
|
|
214
216
|
corpName: "测试企业",
|
|
217
|
+
credentialKind: "suite",
|
|
215
218
|
permanentCode: "perm_a",
|
|
216
219
|
authMode: 0,
|
|
217
220
|
}, storeDeps());
|
|
@@ -241,6 +244,7 @@ describe("B1-B:授权模式(auth_mode)", () => {
|
|
|
241
244
|
agentId: "100001",
|
|
242
245
|
env: "prod",
|
|
243
246
|
corpName: "测试企业",
|
|
247
|
+
credentialKind: "suite",
|
|
244
248
|
permanentCode: "perm_a",
|
|
245
249
|
authMode: 0,
|
|
246
250
|
}, storeDeps());
|
|
@@ -266,6 +270,7 @@ describe("B1-D:授权探针(同步入口的准入事实)", () => {
|
|
|
266
270
|
agentId,
|
|
267
271
|
env: "prod",
|
|
268
272
|
corpName: null,
|
|
273
|
+
credentialKind: "suite",
|
|
269
274
|
permanentCode: `perm_${agentId}`,
|
|
270
275
|
authMode,
|
|
271
276
|
}, storeDeps());
|
|
@@ -276,7 +281,7 @@ describe("B1-D:授权探针(同步入口的准入事实)", () => {
|
|
|
276
281
|
|
|
277
282
|
it("装了 → 拿得到 agent + 授权模式;带 agent 可收窄,指错应用 → null", async () => {
|
|
278
283
|
await profile("100001", 1);
|
|
279
|
-
expect(await probeProfile("ww_corp_a", null)).toEqual({ agentId: "100001", authMode: 1, status: "active" });
|
|
284
|
+
expect(await probeProfile("ww_corp_a", null)).toEqual({ agentId: "100001", authMode: 1, status: "active", credentialKind: "suite" });
|
|
280
285
|
expect(await probeProfile("ww_corp_a", "100001")).toMatchObject({ authMode: 1 });
|
|
281
286
|
expect(await probeProfile("ww_corp_a", "200002")).toBeNull();
|
|
282
287
|
});
|
|
@@ -447,6 +452,7 @@ describe("C1:通讯录事件增量(change_contact)", () => {
|
|
|
447
452
|
agentId: "100001",
|
|
448
453
|
env: "prod",
|
|
449
454
|
corpName: "测试企业",
|
|
455
|
+
credentialKind: "suite",
|
|
450
456
|
permanentCode: "perm_a",
|
|
451
457
|
authMode: 0,
|
|
452
458
|
}, storeDeps());
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"react-router": "7.18.2",
|
|
9
9
|
"react-router/dom": "7.18.2",
|
|
10
10
|
"@tanstack/react-query": "5.102.8",
|
|
11
|
-
"@oj-module/runtime": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract": "0.2.
|
|
13
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
11
|
+
"@oj-module/runtime": "0.2.50",
|
|
12
|
+
"@oj-module/runtime/contract": "0.2.50",
|
|
13
|
+
"@oj-module/runtime/contract/errors": "0.2.50",
|
|
14
14
|
"@plane/kit": "0.1.20",
|
|
15
15
|
"@plane/editor": "0.1.20",
|
|
16
16
|
"antd": "6.6.1",
|