@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oj-module/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.50",
|
|
5
5
|
"description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@faker-js/faker": "^10.4.0",
|
|
52
|
-
"@oj-module/runtime": "0.2.
|
|
52
|
+
"@oj-module/runtime": "0.2.50",
|
|
53
53
|
"@redocly/cli": "^2.51.0",
|
|
54
54
|
"@tailwindcss/vite": "^4.3.0",
|
|
55
55
|
"cac": "^6.7.14",
|
|
@@ -304,10 +304,10 @@ function getAppInfo() {
|
|
|
304
304
|
return {
|
|
305
305
|
"pkg": {
|
|
306
306
|
"name": "@oj-module/runtime",
|
|
307
|
-
"version": "0.2.
|
|
307
|
+
"version": "0.2.50",
|
|
308
308
|
"license": "MIT"
|
|
309
309
|
},
|
|
310
|
-
"lastBuildTime": "2026-11-01
|
|
310
|
+
"lastBuildTime": "2026-11-01 22:25:12"
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
var init_get_app_info = __esmMin((() => {}));
|
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-milCmNXuFsJqtDXLlLdu/g=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="
|
|
10
|
+
<script type="importmap" nonce="milCmNXuFsJqtDXLlLdu/g==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.50",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.50",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.50",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -116,3 +116,83 @@ describe("given module-root 布局 When ojm build 后端", () => {
|
|
|
116
116
|
expect(existsSync(join(root, "dist", "api"))).toBe(false);
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 假 oj:`secret open` 按 openOk 成败(失败时复述 oj 的真实 stderr),`build` 记 argv 后成功。
|
|
122
|
+
* 只记非 secret 调用,便于断言「预检之后 build 是否真的跑了」。
|
|
123
|
+
*/
|
|
124
|
+
function fakeOjSeal(projectRoot: string, openOk: boolean): void {
|
|
125
|
+
mkdirSync(join(projectRoot, "bin"), { recursive: true });
|
|
126
|
+
const bin = join(projectRoot, "bin", "oj");
|
|
127
|
+
writeFileSync(bin, [
|
|
128
|
+
"#!/bin/sh",
|
|
129
|
+
"if [ \"$1\" = \"secret\" ]; then",
|
|
130
|
+
...(openOk ? [] : [" echo 'oj secret open: aes-gcm open failed (sealed value tampered?): aead::Error' >&2"]),
|
|
131
|
+
` exit ${openOk ? 0 : 1}`,
|
|
132
|
+
"fi",
|
|
133
|
+
"printf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/invoked\"",
|
|
134
|
+
"exit 0",
|
|
135
|
+
"",
|
|
136
|
+
].join("\n"), { mode: 0o755 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** flat 布局 + 已密封的 config(路径照 ojm init 的产物写) */
|
|
140
|
+
function newSealedProject(): string {
|
|
141
|
+
const root = newProject();
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(root, "api", "config.yaml"),
|
|
144
|
+
"auth:\n jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\n"
|
|
145
|
+
+ "secrets:\n private_key_path: ./config/secrets-private.pem\n",
|
|
146
|
+
"utf-8",
|
|
147
|
+
);
|
|
148
|
+
return root;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("given config 含密封值 When ojm build 后端", () => {
|
|
152
|
+
// oj build 读不动 config 时只 warn 两行、退出码 0,继续出包时 sql_guard 是关掉的。
|
|
153
|
+
// ojm 必须在把 oj 叫起来之前就停下,否则「没跑过 tenant 校验的包」会流到部署机上。
|
|
154
|
+
it("then 解不开就停在出包前,不当成一次成功构建", async () => {
|
|
155
|
+
const root = newSealedProject();
|
|
156
|
+
fakeOjSeal(root, false);
|
|
157
|
+
|
|
158
|
+
await expect(buildBackend(root)).rejects.toThrow(/密封值解不开/);
|
|
159
|
+
await expect(buildBackend(root)).rejects.toThrow(/secrets-private\.pem/);
|
|
160
|
+
expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("then 解不开时把 oj 的原话带上(排障不用再跑一遍)", async () => {
|
|
164
|
+
const root = newSealedProject();
|
|
165
|
+
fakeOjSeal(root, false);
|
|
166
|
+
|
|
167
|
+
await expect(buildBackend(root)).rejects.toThrow(/aes-gcm open failed/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("then 能解开就照常出包", async () => {
|
|
171
|
+
const root = newSealedProject();
|
|
172
|
+
fakeOjSeal(root, true);
|
|
173
|
+
|
|
174
|
+
expect(await buildBackend(root)).toBe(true);
|
|
175
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).toContain("build -c ");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("then config 无密封值时不额外调用 oj(预检零成本)", async () => {
|
|
179
|
+
const root = newProject(); // config 只有 server.port
|
|
180
|
+
fakeOjSeal(root, false);
|
|
181
|
+
|
|
182
|
+
expect(await buildBackend(root)).toBe(true);
|
|
183
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("then 注释掉的旧密文不算数(不因一行 `#` 挡住构建)", async () => {
|
|
187
|
+
const root = newProject();
|
|
188
|
+
writeFileSync(
|
|
189
|
+
join(root, "api", "config.yaml"),
|
|
190
|
+
"# auth:\n# jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\nserver:\n port: 9778\n",
|
|
191
|
+
"utf-8",
|
|
192
|
+
);
|
|
193
|
+
fakeOjSeal(root, false);
|
|
194
|
+
|
|
195
|
+
expect(await buildBackend(root)).toBe(true);
|
|
196
|
+
expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/build.ts
CHANGED
|
@@ -584,6 +584,9 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
|
|
|
584
584
|
// v0.1.85:`--module-root <dir>` 与 `-d` 互斥(都是「源码树根」)。新布局给 src,
|
|
585
585
|
// oj 在子树内递归找 manifest.yaml(命中 src/<name>/api/manifest.yaml 即停)。
|
|
586
586
|
const sourceArgs = layout?.moduleRoot ? ["--module-root", layout.moduleRoot] : ["-d", apiSrc];
|
|
587
|
+
// 出包前先自己确认 config 能读:读不动时 oj build 会静默关掉 sql_guard(见函数注释)
|
|
588
|
+
if (configArgs.length)
|
|
589
|
+
assertSealedValuesOpenable(oj, configPath);
|
|
587
590
|
execFileSync(oj, [
|
|
588
591
|
"build",
|
|
589
592
|
...configArgs,
|
|
@@ -604,6 +607,53 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
|
|
|
604
607
|
return true;
|
|
605
608
|
}
|
|
606
609
|
|
|
610
|
+
/**
|
|
611
|
+
* config 里的密封值(`oj secret seal` 产物)在 build 前试解一把——fail-closed。
|
|
612
|
+
*
|
|
613
|
+
* **为什么不能只信 oj 的 warn**:实测(oj 0.1.115)config 读不动时 `oj build` **退出码仍是 0**,
|
|
614
|
+
* 只往 stderr 甩两行「读配置失败 → 镜像目录回落默认 tasks / 本次 build 按 sql_guard=off 处理」,
|
|
615
|
+
* 然后照常出包。那两行夹在模块清单里极易被忽略,而关掉的 `sql_guard` 正是 tenant 声明校验
|
|
616
|
+
* (跨模块 SQL 的唯一守卫)——包看起来完好,检查其实没跑。
|
|
617
|
+
*
|
|
618
|
+
* 探法就是 oj 自己的排障命令 `oj secret open`:它和 `oj serve` / `oj build` 用同一套密钥解析
|
|
619
|
+
* (`OJ_SECRET_KEY` → `OJ_SECRET_KEY_FILE` → config 的 `secrets.private_key_path`),
|
|
620
|
+
* 解不开就非零退出。明文只可能出现在它的 stdout,这里直接丢弃,不落终端也不落日志。
|
|
621
|
+
*/
|
|
622
|
+
function assertSealedValuesOpenable(oj: string, configPath: string): void {
|
|
623
|
+
// 注释行不算:config 里留着注释掉的旧密文很常见,naive 匹配会让 build 因为一行 `#` 而失败
|
|
624
|
+
const live = fs.readFileSync(configPath, "utf-8")
|
|
625
|
+
.split("\n")
|
|
626
|
+
.filter(line => !line.trimStart().startsWith("#"))
|
|
627
|
+
.join("\n");
|
|
628
|
+
const sealed = [...new Set(live.match(/ENC\[[^\]\n]+\]/g) ?? [])];
|
|
629
|
+
for (const value of sealed) {
|
|
630
|
+
try {
|
|
631
|
+
execFileSync(oj, ["secret", "open", "-c", configPath, value], { stdio: ["ignore", "ignore", "pipe"] });
|
|
632
|
+
}
|
|
633
|
+
catch (e) {
|
|
634
|
+
const stderr = String((e as { stderr?: unknown }).stderr ?? "").trim();
|
|
635
|
+
const detail = stderr || (e as Error).message;
|
|
636
|
+
throw new Error(
|
|
637
|
+
`[ojm] config 里的密封值解不开,已停在出包前:${configPath}\n`
|
|
638
|
+
+ ` oj 说:${detail}\n`
|
|
639
|
+
+ "\n"
|
|
640
|
+
+ "不能放行:oj build 遇到读不动的 config 不会失败(退出码 0),只 warn 两行就继续——\n"
|
|
641
|
+
+ "tasks 镜像目录回落默认值,sql_guard(tenant 声明校验)直接关掉。那种包看着正常,\n"
|
|
642
|
+
+ "跨模块 SQL 的守卫却没跑过。\n"
|
|
643
|
+
+ "\n"
|
|
644
|
+
+ "两类原因,都修得了:\n"
|
|
645
|
+
+ " 1) 本机加载的私钥与密封时用的公钥不是一对——最常见是换机器/换部署目录时只带了\n"
|
|
646
|
+
+ " config.yaml,没带 config/secrets-private.pem;\n"
|
|
647
|
+
+ " 2) 密文被改过——手工编辑、复制被截断、编辑器把长行折了。\n"
|
|
648
|
+
+ "\n"
|
|
649
|
+
+ "单独验一条:oj secret open -c <config> '<那个 ENC[...] 值>'(成功=这条没问题,失败=就是它)\n"
|
|
650
|
+
+ "修法:把配对私钥带到本机(OJ_SECRET_KEY / OJ_SECRET_KEY_FILE / config 的 secrets.private_key_path\n"
|
|
651
|
+
+ " 三选一);密钥确实换了,就在本机重新 oj secret seal 覆写该值(明文走 stdin,别上命令行)。",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
607
657
|
/**
|
|
608
658
|
* 全站合并(仅 `ojm build` 调用,设计 §5):清场后拷 shell dist 全量,
|
|
609
659
|
* 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
|
package/src/init.ts
CHANGED
|
@@ -193,10 +193,11 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
193
193
|
if (fs.existsSync(path.join(cliDevkit, "SKILL.md"))) {
|
|
194
194
|
const ojmSkillDir = path.join(destDir, ".claude/skills/ojm-module-dev");
|
|
195
195
|
fs.mkdirSync(ojmSkillDir, { recursive: true });
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
196
|
+
// 企微两本手册随之分发——它们讲的是「怎么把一个已经写好的连接器接上真实企业」,
|
|
197
|
+
// 业务工程拿到模板后自助完成对接要用,不能只留本仓:
|
|
198
|
+
// · `wecom-daikaifa.md` 企业微信代开发(服务商模板):凭据清单 + 用户/开发/部署三视角;
|
|
199
|
+
// · `wecom-internal-app.md` 企业内部应用(自建):五项 corp 凭据 + 验证并接入 + 共存。
|
|
200
|
+
for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md", "wecom-internal-app.md"])
|
|
200
201
|
copyIfAbsent(path.join(cliDevkit, name), path.join(ojmSkillDir, name), report);
|
|
201
202
|
}
|
|
202
203
|
else {
|
package/templates/config.yaml
CHANGED
|
@@ -56,8 +56,13 @@ vars:
|
|
|
56
56
|
# 故意不读请求头 Host(防伪造 Host 牵引出站):不配 = fail-closed,不会替你猜。
|
|
57
57
|
# canonical_origin: "https://your-site.example.com"
|
|
58
58
|
|
|
59
|
-
# ----
|
|
60
|
-
#
|
|
59
|
+
# ---- 企业微信(部署级常量,不进 DB)----
|
|
60
|
+
# 两种应用来源,**可同时启用**(docs/plans/20261101-2044-wecom-internal-app-plan.md):
|
|
61
|
+
# · 代开发(suite):一套代开发模板,企业扫码授权即接入,一套部署可服务多家企业;
|
|
62
|
+
# · 企业内部应用 / 自建应用(corp):企业自建应用,一套部署只服务一家企业。
|
|
63
|
+
# 两套的企业侧数据链路相同(permanent_code 就是当 corpsecret 用的),差别只在凭据来源与回调。
|
|
64
|
+
# 从代开发迁到自建时请把旧的 suite 变量清空;两套同时生效须显式开 wecom_allow_coexist。
|
|
65
|
+
# 一个部署 = 一个服务商账号下的一套代开发模板(或一家企业的自建应用),所以这些值是「部署」维度,不进 DB。
|
|
61
66
|
# 「哪个企业装了哪个应用」不写在这里:企微 create_auth 回调推 AuthCode → 换永久授权码 →
|
|
62
67
|
# 落 wecom_app_profile(主键含 agent_id,同一企业测试/正式两个应用天然并存),
|
|
63
68
|
# 新增企业只需企业管理员扫码授权,**不改配置、不重启**。
|
|
@@ -74,18 +79,35 @@ vars:
|
|
|
74
79
|
# wecom_provider_id 服务商的 corpid(服务商自己的,不是企业 corpid);**位置:服务商后台「服务商信息」页显示的 CorpID**
|
|
75
80
|
# wecom_provider_secret 服务商 provider_secret;**位置:服务商后台 → 应用管理 → 通用开发参数(点「编辑」)→ ProviderSecret 字段**,
|
|
76
81
|
# **不在「服务商信息」页**(那里只显示 CorpID),也**不是** suite_secret(suite 那个是另一套)。
|
|
77
|
-
# wecom_callback_token
|
|
78
|
-
# wecom_encoding_aes_key
|
|
79
|
-
# wecom_enc_key permanent_code
|
|
82
|
+
# wecom_callback_token 代开发「指令回调」的 Token(回调验签用)
|
|
83
|
+
# wecom_encoding_aes_key 代开发「指令回调」的 EncodingAESKey(43 字符,回调解密用)
|
|
84
|
+
# wecom_enc_key permanent_code / 应用 Secret 的落库加密密钥(hex/base64,16 或 32 字节;建议 oj secret keygen)
|
|
80
85
|
# wecom_base_url 连接器基址(绝对地址,无尾斜杠,**这是基址、不是回调 URL**),如 https://site.example.com/api/auth/wecom
|
|
81
|
-
# →
|
|
86
|
+
# → 回调 URL 填 <base>/event(必须带 /event,裸 <base> 无 handler 会 404);
|
|
82
87
|
# 网页授权 redirect_uri 用 <base>/callback
|
|
88
|
+
#
|
|
89
|
+
# 企业内部应用(corp)——一套部署一家企业:
|
|
90
|
+
# wecom_corp_id 企微后台「我的企业 → 企业信息 → 企业ID」;同时是租户键
|
|
91
|
+
# wecom_corp_secret 应用详情页「Secret」;**换过后必须到 /system/wecom 重新「验证并接入」**
|
|
92
|
+
# wecom_corp_agent_id 应用详情页「AgentId」
|
|
93
|
+
# wecom_corp_callback_token 「管理工具 → 通讯录同步 → 接收事件服务器」的 Token(可选;不配 = 不做事件增量)
|
|
94
|
+
# wecom_corp_encoding_aes_key 同上 → EncodingAESKey(可选)
|
|
95
|
+
# ⚠ 自建应用**收不到**成员通讯录变更事件(官方 90970),事件增量只能靠上面这组「通讯录同步」回调,
|
|
96
|
+
# 且企微要求该 URL 在企业主体域名下——所以它只适用于私有化单企业部署。
|
|
97
|
+
#
|
|
98
|
+
# wecom_allow_coexist 两套凭据都配齐时必须显式写 'true' 才允许共存(否则 fail-fast)
|
|
83
99
|
wecom_suite_id: ''
|
|
84
100
|
wecom_suite_secret: ''
|
|
85
101
|
wecom_provider_id: ''
|
|
86
102
|
wecom_provider_secret: ''
|
|
87
103
|
wecom_callback_token: ''
|
|
88
104
|
wecom_encoding_aes_key: ''
|
|
105
|
+
wecom_corp_id: ''
|
|
106
|
+
wecom_corp_secret: ''
|
|
107
|
+
wecom_corp_agent_id: ''
|
|
108
|
+
wecom_corp_callback_token: ''
|
|
109
|
+
wecom_corp_encoding_aes_key: ''
|
|
110
|
+
wecom_allow_coexist: ''
|
|
89
111
|
wecom_enc_key: ''
|
|
90
112
|
wecom_base_url: ''
|
|
91
113
|
|
package/templates/openapi.json
CHANGED
|
@@ -1771,7 +1771,8 @@
|
|
|
1771
1771
|
"status",
|
|
1772
1772
|
"auth_mode",
|
|
1773
1773
|
"installed_at",
|
|
1774
|
-
"update_time"
|
|
1774
|
+
"update_time",
|
|
1775
|
+
"credential_kind"
|
|
1775
1776
|
],
|
|
1776
1777
|
"properties": {
|
|
1777
1778
|
"suite_id": {
|
|
@@ -1802,6 +1803,13 @@
|
|
|
1802
1803
|
"disabled"
|
|
1803
1804
|
]
|
|
1804
1805
|
},
|
|
1806
|
+
"credential_kind": {
|
|
1807
|
+
"type": "string",
|
|
1808
|
+
"enum": [
|
|
1809
|
+
"suite",
|
|
1810
|
+
"corp"
|
|
1811
|
+
]
|
|
1812
|
+
},
|
|
1805
1813
|
"auth_mode": {
|
|
1806
1814
|
"type": "number"
|
|
1807
1815
|
},
|
|
@@ -1960,6 +1968,151 @@
|
|
|
1960
1968
|
}
|
|
1961
1969
|
}
|
|
1962
1970
|
},
|
|
1971
|
+
"/api/auth/wecom/admin/overview": {
|
|
1972
|
+
"get": {
|
|
1973
|
+
"operationId": "auth_wecom_admin_overview_get",
|
|
1974
|
+
"summary": "GET api/auth/wecom/admin/overview",
|
|
1975
|
+
"x-oj-file": "auth/wecom/admin/api.ts",
|
|
1976
|
+
"tags": [
|
|
1977
|
+
"auth"
|
|
1978
|
+
],
|
|
1979
|
+
"responses": {
|
|
1980
|
+
"200": {
|
|
1981
|
+
"description": "OK",
|
|
1982
|
+
"x-oj-response-enforced": false,
|
|
1983
|
+
"content": {
|
|
1984
|
+
"application/json": {
|
|
1985
|
+
"schema": {
|
|
1986
|
+
"type": "object",
|
|
1987
|
+
"required": [
|
|
1988
|
+
"code",
|
|
1989
|
+
"msg",
|
|
1990
|
+
"data"
|
|
1991
|
+
],
|
|
1992
|
+
"properties": {
|
|
1993
|
+
"code": {
|
|
1994
|
+
"type": "integer"
|
|
1995
|
+
},
|
|
1996
|
+
"msg": {
|
|
1997
|
+
"type": "string"
|
|
1998
|
+
},
|
|
1999
|
+
"data": {
|
|
2000
|
+
"type": "object",
|
|
2001
|
+
"required": [
|
|
2002
|
+
"kinds",
|
|
2003
|
+
"configError",
|
|
2004
|
+
"callbackUrl",
|
|
2005
|
+
"corpId",
|
|
2006
|
+
"agentId",
|
|
2007
|
+
"corpCallbackConfigured"
|
|
2008
|
+
],
|
|
2009
|
+
"properties": {
|
|
2010
|
+
"kinds": {
|
|
2011
|
+
"type": "array",
|
|
2012
|
+
"items": {
|
|
2013
|
+
"type": "string",
|
|
2014
|
+
"enum": [
|
|
2015
|
+
"suite",
|
|
2016
|
+
"corp"
|
|
2017
|
+
]
|
|
2018
|
+
}
|
|
2019
|
+
},
|
|
2020
|
+
"configError": {
|
|
2021
|
+
"type": [
|
|
2022
|
+
"string",
|
|
2023
|
+
"null"
|
|
2024
|
+
]
|
|
2025
|
+
},
|
|
2026
|
+
"callbackUrl": {
|
|
2027
|
+
"type": [
|
|
2028
|
+
"string",
|
|
2029
|
+
"null"
|
|
2030
|
+
]
|
|
2031
|
+
},
|
|
2032
|
+
"corpId": {
|
|
2033
|
+
"type": [
|
|
2034
|
+
"string",
|
|
2035
|
+
"null"
|
|
2036
|
+
]
|
|
2037
|
+
},
|
|
2038
|
+
"agentId": {
|
|
2039
|
+
"type": [
|
|
2040
|
+
"string",
|
|
2041
|
+
"null"
|
|
2042
|
+
]
|
|
2043
|
+
},
|
|
2044
|
+
"corpCallbackConfigured": {
|
|
2045
|
+
"type": "boolean"
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
},
|
|
2057
|
+
"/api/auth/wecom/admin/connect": {
|
|
2058
|
+
"post": {
|
|
2059
|
+
"operationId": "auth_wecom_admin_connect_post",
|
|
2060
|
+
"summary": "POST api/auth/wecom/admin/connect",
|
|
2061
|
+
"x-oj-file": "auth/wecom/admin/api.ts",
|
|
2062
|
+
"tags": [
|
|
2063
|
+
"auth"
|
|
2064
|
+
],
|
|
2065
|
+
"responses": {
|
|
2066
|
+
"200": {
|
|
2067
|
+
"description": "OK",
|
|
2068
|
+
"x-oj-response-enforced": false,
|
|
2069
|
+
"content": {
|
|
2070
|
+
"application/json": {
|
|
2071
|
+
"schema": {
|
|
2072
|
+
"type": "object",
|
|
2073
|
+
"required": [
|
|
2074
|
+
"code",
|
|
2075
|
+
"msg",
|
|
2076
|
+
"data"
|
|
2077
|
+
],
|
|
2078
|
+
"properties": {
|
|
2079
|
+
"code": {
|
|
2080
|
+
"type": "integer"
|
|
2081
|
+
},
|
|
2082
|
+
"msg": {
|
|
2083
|
+
"type": "string"
|
|
2084
|
+
},
|
|
2085
|
+
"data": {
|
|
2086
|
+
"type": "object",
|
|
2087
|
+
"required": [
|
|
2088
|
+
"corpId",
|
|
2089
|
+
"agentId",
|
|
2090
|
+
"departments",
|
|
2091
|
+
"members"
|
|
2092
|
+
],
|
|
2093
|
+
"properties": {
|
|
2094
|
+
"corpId": {
|
|
2095
|
+
"type": "string"
|
|
2096
|
+
},
|
|
2097
|
+
"agentId": {
|
|
2098
|
+
"type": "string"
|
|
2099
|
+
},
|
|
2100
|
+
"departments": {
|
|
2101
|
+
"type": "number"
|
|
2102
|
+
},
|
|
2103
|
+
"members": {
|
|
2104
|
+
"type": "number"
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
1963
2116
|
"/api/auth/wecom/login": {
|
|
1964
2117
|
"get": {
|
|
1965
2118
|
"operationId": "auth_wecom_login_get",
|
|
@@ -4888,7 +5041,9 @@
|
|
|
4888
5041
|
"errDetail",
|
|
4889
5042
|
"startedAt",
|
|
4890
5043
|
"finishedAt",
|
|
4891
|
-
"updateTime"
|
|
5044
|
+
"updateTime",
|
|
5045
|
+
"agentId",
|
|
5046
|
+
"credentialKind"
|
|
4892
5047
|
],
|
|
4893
5048
|
"properties": {
|
|
4894
5049
|
"connected": {
|
|
@@ -4998,6 +5153,23 @@
|
|
|
4998
5153
|
},
|
|
4999
5154
|
"updateTime": {
|
|
5000
5155
|
"type": "number"
|
|
5156
|
+
},
|
|
5157
|
+
"agentId": {
|
|
5158
|
+
"type": [
|
|
5159
|
+
"string",
|
|
5160
|
+
"null"
|
|
5161
|
+
]
|
|
5162
|
+
},
|
|
5163
|
+
"credentialKind": {
|
|
5164
|
+
"type": [
|
|
5165
|
+
"string",
|
|
5166
|
+
"null"
|
|
5167
|
+
],
|
|
5168
|
+
"enum": [
|
|
5169
|
+
"suite",
|
|
5170
|
+
"corp",
|
|
5171
|
+
null
|
|
5172
|
+
]
|
|
5001
5173
|
}
|
|
5002
5174
|
}
|
|
5003
5175
|
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 企微后台(服务商运营面 + 自建应用接入面):企业应用清单、启停、开通入口、验证并接入。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 四个反常识点,都是踩过的坑:
|
|
4
4
|
// 1. **不读部署常量也能停用**:`wecom_base_url` 配错时正是最需要「先把这个企业停掉」的时刻,
|
|
5
|
-
// 所以清单与启停都不依赖 `
|
|
5
|
+
// 所以清单与启停都不依赖 `readSuiteConfig`,只有开通入口要(它得调企微换预授权码)。
|
|
6
6
|
// 2. **不能用 `asTenant` 清会话**:管理员请求自带自己的租户头(多半是 `default`),
|
|
7
7
|
// 而 `asTenant` 只能在「请求还没有租户」时调用,否则抛错。故吊销目标企业会话走带
|
|
8
8
|
// `tenant_id = ?` 的裸 SQL(守卫是 check-on-miss,带了租户条件即放行)。
|
|
9
9
|
// 3. **停用单个应用 ≠ 停用企业**:会话是按租户(= 企业)发的,一个企业可能有测试/正式两个应用。
|
|
10
10
|
// 只有该企业**再没有启用中的应用**时才吊销会话——否则停用测试应用会把正式环境的人踢下线。
|
|
11
11
|
import type { WecomClient } from "./client";
|
|
12
|
-
import type { WecomConfig } from "./config";
|
|
12
|
+
import type { WecomConfig, WecomCorpConfig, WecomKind } from "./config";
|
|
13
13
|
import type { WecomStoreDeps } from "./store";
|
|
14
14
|
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
15
15
|
import { assertProviderConfig, eventUrlOf } from "./config";
|
|
16
|
-
import { corpScope, revokeTenantSessions, tenantOfCorp } from "./store";
|
|
16
|
+
import { corpScope, revokeTenantSessions, saveProfile, tenantOfCorp } from "./store";
|
|
17
17
|
|
|
18
18
|
export interface WecomAdminDeps {
|
|
19
19
|
client: WecomClient
|
|
@@ -29,7 +29,9 @@ export interface AppRow {
|
|
|
29
29
|
env: string
|
|
30
30
|
corp_name: string | null
|
|
31
31
|
status: string
|
|
32
|
-
/**
|
|
32
|
+
/** 应用来源:suite=代开发 / corp=企业内部应用(自建) */
|
|
33
|
+
credential_kind: WecomKind
|
|
34
|
+
/** 0=管理员授权 / 1=成员授权:成员授权下这家企业的应用做不了组织架构同步(§2.3);自建行恒 0 */
|
|
33
35
|
auth_mode: number
|
|
34
36
|
installed_at: number
|
|
35
37
|
update_time: number
|
|
@@ -37,7 +39,7 @@ export interface AppRow {
|
|
|
37
39
|
|
|
38
40
|
export async function listApps(): Promise<AppRow[]> {
|
|
39
41
|
const rows = await db.table("wecom_app_profile")
|
|
40
|
-
.select(["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "corp_name", "status", "auth_mode", "installed_at", "update_time"])
|
|
42
|
+
.select(["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "corp_name", "status", "credential_kind", "auth_mode", "installed_at", "update_time"])
|
|
41
43
|
.all() as unknown as AppRow[];
|
|
42
44
|
// 稳定序:先按企业、再按应用;同企业多环境并排,运维一眼能看出「哪两个是同一家」
|
|
43
45
|
return rows.sort((a, b) => a.corp_id.localeCompare(b.corp_id) || a.agent_id.localeCompare(b.agent_id));
|
|
@@ -117,3 +119,81 @@ export async function createInstall(
|
|
|
117
119
|
expiresIn: auth.expiresIn,
|
|
118
120
|
};
|
|
119
121
|
}
|
|
122
|
+
|
|
123
|
+
/** 接入校验最多探多少个部门找人(顶层部门常只有子部门,只看第一个会误判「读不到人」) */
|
|
124
|
+
const CONNECT_SAMPLE_DEPTS = 10;
|
|
125
|
+
|
|
126
|
+
export interface ConnectResult {
|
|
127
|
+
corpId: string
|
|
128
|
+
agentId: string
|
|
129
|
+
/** 应用可见范围内的部门数 */
|
|
130
|
+
departments: number
|
|
131
|
+
/** 示例部门里的直属成员数(>0 才说明通讯录权限真的通了) */
|
|
132
|
+
members: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 自建应用「验证并接入」:**四步全过才落库**(plan §4.3)。
|
|
137
|
+
*
|
|
138
|
+
* 为什么不能启动时自动写一行:自检失败会变成启动失败,而自建模式最常见的失败是客户还没加可信 IP、
|
|
139
|
+
* 可见范围没配——那是运营动作,不该让服务起不来。所以这一步放在人点的按钮后面。
|
|
140
|
+
*
|
|
141
|
+
* 顺序有讲究:
|
|
142
|
+
* 1. 先**作废 token 缓存**再换 token。换过 Secret 的部署若命中旧缓存,校验会「通过」,
|
|
143
|
+
* 而真正的问题(旧 secret 已失效)会在下一个登录请求上以 40001 冒出来。
|
|
144
|
+
* 2. gettoken 验 secret、department/simplelist 验通讯录读权限、user/simplelist 验能读到人——
|
|
145
|
+
* 这三件事对应三个最容易配错的开关(Secret 抄错 / 可见范围空 / 通讯录权限没给)。
|
|
146
|
+
*/
|
|
147
|
+
export async function connectCorp(cfg: WecomCorpConfig, client: WecomClient, deps: WecomStoreDeps): Promise<ConnectResult> {
|
|
148
|
+
await client.invalidateCorpToken(cfg.corpId, cfg.agentId);
|
|
149
|
+
await client.corpAccessToken(cfg.corpId, cfg.agentId, cfg.corpSecret);
|
|
150
|
+
|
|
151
|
+
const depts = await client.departmentChildren(cfg.corpId, cfg.agentId, cfg.corpSecret);
|
|
152
|
+
if (!depts.length)
|
|
153
|
+
throw Object.assign(new Error("接入校验失败:应用可见范围里没有任何部门(把「应用可见范围」设为全公司,或至少包含一个部门)"), { status: 400 });
|
|
154
|
+
|
|
155
|
+
// 顶层部门常常只有子部门、没有直属成员,所以往后再看几个再下结论
|
|
156
|
+
let members = 0;
|
|
157
|
+
for (const d of depts.slice(0, CONNECT_SAMPLE_DEPTS)) {
|
|
158
|
+
members = (await client.deptMembers(cfg.corpId, cfg.agentId, cfg.corpSecret, d.id)).length;
|
|
159
|
+
if (members)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (!members)
|
|
163
|
+
throw Object.assign(new Error("接入校验失败:可见范围内读不到成员(请把应用可见范围扩大到至少一名成员)"), { status: 400 });
|
|
164
|
+
|
|
165
|
+
await saveProfile({
|
|
166
|
+
// corp 行没有套件:suite_id 用空串(主键第一段),来源由 credential_kind 表达
|
|
167
|
+
suiteId: "",
|
|
168
|
+
corpId: cfg.corpId,
|
|
169
|
+
authCorpId: cfg.corpId,
|
|
170
|
+
agentId: cfg.agentId,
|
|
171
|
+
env: "prod",
|
|
172
|
+
corpName: null,
|
|
173
|
+
credentialKind: "corp",
|
|
174
|
+
permanentCode: cfg.corpSecret,
|
|
175
|
+
// 自建应用没有「管理员授权 / 成员授权」之分:恒 0,同步闸门据此直接放行
|
|
176
|
+
authMode: 0,
|
|
177
|
+
}, deps);
|
|
178
|
+
|
|
179
|
+
// 换过 AgentId 的部署会留下旧应用的活跃行:旧行的 secret 已不可用,留着只会让
|
|
180
|
+
// 「同一企业多应用」的分辨逻辑误判,故随本次接入一并停用。
|
|
181
|
+
const stale = await db.table("wecom_app_profile").select(["agent_id"]).where({
|
|
182
|
+
and: [
|
|
183
|
+
corpScope(cfg.corpId),
|
|
184
|
+
{ field: "credential_kind", op: "eq", value: "corp" },
|
|
185
|
+
{ field: "status", op: "eq", value: "active" },
|
|
186
|
+
],
|
|
187
|
+
}).all() as Array<{ agent_id: string }>;
|
|
188
|
+
for (const r of stale) {
|
|
189
|
+
const old = String(r.agent_id);
|
|
190
|
+
if (old === cfg.agentId)
|
|
191
|
+
continue;
|
|
192
|
+
await db.table("wecom_app_profile").update({ status: "disabled", update_time: deps.now() }).where({
|
|
193
|
+
and: [corpScope(cfg.corpId), { field: "agent_id", op: "eq", value: old }],
|
|
194
|
+
}).run();
|
|
195
|
+
await client.invalidateCorpToken(cfg.corpId, old);
|
|
196
|
+
}
|
|
197
|
+
invalidateAllUsers();
|
|
198
|
+
return { corpId: cfg.corpId, agentId: cfg.agentId, departments: depts.length, members };
|
|
199
|
+
}
|