@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.
Files changed (45) hide show
  1. package/devkit/README.md +9 -7
  2. package/devkit/SKILL.md +53 -14
  3. package/devkit/manual.md +203 -3
  4. package/devkit/wecom-daikaifa.md +100 -1
  5. package/devkit/wecom-internal-app.md +643 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +2 -2
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +80 -0
  11. package/src/build.ts +50 -0
  12. package/src/init.ts +5 -4
  13. package/templates/config.yaml +28 -6
  14. package/templates/openapi.json +174 -2
  15. package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
  16. package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
  17. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  18. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  19. package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
  20. package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
  21. package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
  22. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  23. package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
  24. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  25. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  26. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  27. package/templates/src/auth/api/schema.yaml +2 -0
  28. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  29. package/templates/src/auth/api/wecom/api.ts +151 -59
  30. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  31. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  32. package/templates/src/auth/web/__client/api.ts +47 -1
  33. package/templates/src/login/web/locales/en-US.json +2 -0
  34. package/templates/src/login/web/locales/zh-CN.json +2 -0
  35. package/templates/src/login/web/pages/login.tsx +39 -3
  36. package/templates/src/system/web/locales/en-US.json +24 -1
  37. package/templates/src/system/web/locales/zh-CN.json +24 -1
  38. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  39. package/templates/src/system/web/pages/wecom/index.tsx +164 -46
  40. package/templates/tests/wecom-admin.spec.ts +7 -7
  41. package/templates/tests/wecom-connector.spec.ts +38 -8
  42. package/templates/tests/wecom-corp.spec.ts +523 -0
  43. package/templates/tests/wecom-endpoints.spec.ts +73 -7
  44. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  45. 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
- installVars({ wecom_suite_id: "", wecom_enc_key: "" });
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(/wecom_suite_id/);
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
- it("缺企业代码 → 400,引导去登录页填(不猜默认企业)", async () => {
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
- it("同一条回调连发两次(重放)→ 第二次 400 nonce 重复,票据只落一次", async () => {
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(400);
619
- expect(out.body.msg).toMatch(/nonce 重复/);
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.48",
12
- "@oj-module/runtime/contract": "0.2.48",
13
- "@oj-module/runtime/contract/errors": "0.2.48",
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",