@oj-module/cli 0.2.43 → 0.2.46

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 (96) hide show
  1. package/devkit/README.md +7 -5
  2. package/devkit/SKILL.md +22 -2
  3. package/devkit/manual.md +5 -0
  4. package/devkit/wecom-daikaifa.md +421 -0
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +102 -39
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/manifest-routes-derive.test.ts +22 -0
  10. package/src/__tests__/site-tenant-header.test.ts +54 -0
  11. package/src/__tests__/templates-tenant-scope.test.ts +139 -0
  12. package/src/build.ts +9 -0
  13. package/src/init.ts +4 -1
  14. package/src/install.ts +2 -2
  15. package/src/market/bootstrap.ts +15 -0
  16. package/src/market/setup.ts +3 -3
  17. package/src/market/walkthrough.ts +35 -29
  18. package/src/site.ts +17 -1
  19. package/templates/config.yaml +72 -2
  20. package/templates/openapi.json +319 -0
  21. package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
  22. package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
  23. package/templates/src/_platform/api/_shared/respond.ts +11 -6
  24. package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
  25. package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
  26. package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
  27. package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
  28. package/templates/src/_platform/api/contacts/api.ts +4 -1
  29. package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
  30. package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
  31. package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
  32. package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
  33. package/templates/src/_platform/api/schema.yaml +28 -0
  34. package/templates/src/_platform/api/seed.sql +3 -0
  35. package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
  36. package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
  37. package/templates/src/auth/api/_shared/wecom/config.ts +176 -0
  38. package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -0
  39. package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
  40. package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
  41. package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
  42. package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
  43. package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
  44. package/templates/src/auth/api/manifest.yaml +6 -0
  45. package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
  46. package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
  47. package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
  48. package/templates/src/auth/api/schema.yaml +48 -0
  49. package/templates/src/auth/api/wecom/admin/api.ts +121 -0
  50. package/templates/src/auth/api/wecom/api.ts +246 -0
  51. package/templates/src/auth/web/__client/api.schemas.ts +36 -0
  52. package/templates/src/auth/web/__client/api.ts +148 -1
  53. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
  54. package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
  55. package/templates/src/demo/api/schema.yaml +1 -0
  56. package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
  57. package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
  58. package/templates/src/home/api/schema.yaml +2 -0
  59. package/templates/src/login/web/auth-provider.ts +48 -0
  60. package/templates/src/login/web/entry.ts +21 -1
  61. package/templates/src/login/web/locales/en-US.json +11 -1
  62. package/templates/src/login/web/locales/zh-CN.json +11 -1
  63. package/templates/src/login/web/pages/login.tsx +154 -25
  64. package/templates/src/login/web/wecom.ts +51 -0
  65. package/templates/src/notification/api/_shared/notify.ts +9 -5
  66. package/templates/src/notification/api/api.ts +25 -0
  67. package/templates/src/notification/api/del/api.ts +5 -0
  68. package/templates/src/notification/api/manifest.yaml +3 -0
  69. package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
  70. package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
  71. package/templates/src/notification/api/schema.yaml +4 -0
  72. package/templates/src/personal-center/api/profile/api.ts +13 -0
  73. package/templates/src/personal-center/api/upload/api.ts +8 -1
  74. package/templates/src/system/web/entry.ts +8 -0
  75. package/templates/src/system/web/locales/en-US.json +33 -2
  76. package/templates/src/system/web/locales/zh-CN.json +33 -2
  77. package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
  78. package/templates/src/system/web/pages/users/index.tsx +1 -1
  79. package/templates/src/system/web/pages/wecom/index.tsx +205 -0
  80. package/templates/tests/fixture.ts +91 -11
  81. package/templates/tests/guard.spec.ts +9 -3
  82. package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
  83. package/templates/tests/lifecycle/installer.spec.ts +76 -0
  84. package/templates/tests/lifecycle/trust.spec.ts +1 -1
  85. package/templates/tests/query-utils.spec.ts +12 -9
  86. package/templates/tests/rbac-guard.spec.ts +18 -1
  87. package/templates/tests/tenant-access-cache.spec.ts +99 -0
  88. package/templates/tests/tenant-forgery.spec.ts +152 -0
  89. package/templates/tests/tenant-isolation.spec.ts +133 -0
  90. package/templates/tests/username-namespace.spec.ts +118 -0
  91. package/templates/tests/wecom-admin.spec.ts +386 -0
  92. package/templates/tests/wecom-connector.spec.ts +701 -0
  93. package/templates/tests/wecom-crypt.spec.ts +155 -0
  94. package/templates/tests/wecom-endpoints.spec.ts +468 -0
  95. package/templates/tests/wecom-landing.spec.ts +54 -0
  96. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,230 @@
1
+ // 企微三张表 + 租户上下文的持久化面(全部走构造器,交给 sql_guard 管租户)。
2
+ //
3
+ // 表定性(与 schema.yaml / config.shared_allow 三处一致):
4
+ // wecom_app_profile / wecom_suite_ticket / wecom_login_ticket 均 `tenant: false`(D9)——
5
+ // 它们本身就是「corp → 租户」的映射权威与套件级凭证,查它们的时候必然还没有租户上下文。
6
+ // 共享 = 不受守卫约束,故只放映射/凭证/一次性票据,不放任何租户业务数据。
7
+ //
8
+ // permanent_code(= 代开发应用的 secret,全 corp 数据权限)只以 AES-GCM 密文落库(§7.5),
9
+ // 加解密经 deps 注入:Node 侧测试用假实现,运行时走 crypto.aesGcmEncrypt/Decrypt。
10
+ import type { WecomConfig } from "./config";
11
+
12
+ export interface WecomStoreDeps {
13
+ encrypt: (plain: string) => string
14
+ decrypt: (blob: string) => string
15
+ now: () => number
16
+ }
17
+
18
+ export interface AppProfile {
19
+ suiteId: string
20
+ /** 真实企业 ID(get_permanent_code 的 auth_corp_info.corpid):登录 URL 里的「企业代码」即它 */
21
+ corpId: string
22
+ /** 企微事件里的 AuthCorpId(第三方应用体系下可能是 open_corpid,故与 corp_id 分列存) */
23
+ authCorpId: string
24
+ agentId: string
25
+ /** 测试/正式等部署标签(D5:只作标签,不参与主键) */
26
+ env: string
27
+ corpName: string | null
28
+ status: "active" | "disabled"
29
+ permanentCode: string
30
+ }
31
+
32
+ /** 套件票据:企微每 10 分钟推一次,有效期 30 分钟;过期不得发起任何套件级 API 调用 */
33
+ export interface SuiteTicket {
34
+ ticket: string
35
+ expiresAt: number
36
+ }
37
+
38
+ export interface LoginTicketRow {
39
+ uid: number
40
+ /** 票据所属租户(列名 `owner_tenant`:共享表不声明 tenant_id,见 migration 头注释) */
41
+ ownerTenant: string
42
+ }
43
+
44
+ function fail(status: number, message: string): never {
45
+ throw Object.assign(new Error(message), { status });
46
+ }
47
+
48
+ /**
49
+ * 把请求绑到指定租户(L2 的第四层):浏览器 302 回调带不了自定义头 → 请求是匿名态,
50
+ * 用 db.asTenant(服务端推导值) 补身份。已带头则只做一致性复核(不符即伪造,403)。
51
+ * 注意 asTenant 是**请求级且只能设一次**(oj 手册),故这里不重复调用。
52
+ */
53
+ export function ensureTenant(tenantId: string): void {
54
+ const current = http.tenantId;
55
+ if (current == null || current === "") {
56
+ db.asTenant(tenantId);
57
+ return;
58
+ }
59
+ if (current !== tenantId)
60
+ fail(403, "tenant header does not match the authenticated tenant");
61
+ }
62
+
63
+ /** 企微回调推导出来的租户键:直接就是企业 ID(D10:企业代码=corp_id,故 URL/头/隔离列三者同值) */
64
+ export function tenantOfCorp(corpId: string): string {
65
+ return corpId;
66
+ }
67
+
68
+ /**
69
+ * 企业匹配谓词(corp_id 与 auth_corp_id 双列等价命中:代开发拿到的是 corp_id,
70
+ * 第三方/open_corpid 落到 auth_corp_id)。
71
+ * 抽取理由:清单 / 启停 / 停企业三处原本各写一份 `or: [...]`,任一处漏一列的表现都是
72
+ * 「某类企业查不到」,而它只在真实第三方身份下才出现。
73
+ */
74
+ export function corpScope(corpId: string) {
75
+ return {
76
+ or: [
77
+ { field: "corp_id", op: "eq", value: corpId },
78
+ { field: "auth_corp_id", op: "eq", value: corpId },
79
+ ],
80
+ };
81
+ }
82
+
83
+ /** 企业 + 应用(profile 主键的两段:一次授权多家应用时按 agent 逐个命中) */
84
+ export function corpAgentScope(corpId: string, agentId: string) {
85
+ return {
86
+ and: [
87
+ corpScope(corpId),
88
+ { field: "agent_id", op: "eq", value: agentId },
89
+ ],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * 吊销某租户的全部会话(refresh token)。
95
+ *
96
+ * 用带 `tenant_id` 的裸 SQL 而不是 `truncate()`:后者靠「守卫自动注入当前租户」生效,
97
+ * 而 `db.asTenant` 一次请求只能设一次、且要求当时还没租户——本次请求是否处于匿名态
98
+ * 决定了 truncate 的清理范围,边界交给了上游实现。条件写死在 SQL 里,范围自带。
99
+ */
100
+ export async function revokeTenantSessions(tenantId: string): Promise<number> {
101
+ return Number(await db.exec("DELETE FROM sys_refresh_token WHERE tenant_id = ?", [tenantId]));
102
+ }
103
+
104
+ function isUniqueViolation(e: unknown): boolean {
105
+ return /unique|constraint/i.test(e instanceof Error ? e.message : String(e));
106
+ }
107
+
108
+ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: AppProfile["status"] }, deps: WecomStoreDeps): Promise<void> {
109
+ const now = deps.now();
110
+ const scope = {
111
+ and: [
112
+ { field: "suite_id", op: "eq", value: p.suiteId },
113
+ corpAgentScope(p.corpId, p.agentId),
114
+ ],
115
+ };
116
+ const rows = await db.table("wecom_app_profile").select(["suite_id"]).where(scope).all();
117
+ const cols = {
118
+ auth_corp_id: p.authCorpId,
119
+ env: p.env,
120
+ corp_name: p.corpName,
121
+ status: p.status ?? "active",
122
+ permanent_code_enc: deps.encrypt(p.permanentCode),
123
+ update_time: now,
124
+ };
125
+ if (rows.length) {
126
+ await db.table("wecom_app_profile").update(cols).where(scope).run();
127
+ return;
128
+ }
129
+ try {
130
+ await db.table("wecom_app_profile").insert({ suite_id: p.suiteId, corp_id: p.corpId, agent_id: p.agentId, installed_at: now, ...cols }).run();
131
+ }
132
+ catch (e) {
133
+ // 并发插入者先落(企微 5 秒未拿到 success 就重推,两条 create_auth 撞车是常态):
134
+ // 撞 (suite_id, corp_id, agent_id) 即转为更新,不让它以 500 收场——
135
+ // 否则企微再推第三次,这次授权就有可能在还剩一次重推时被判死。
136
+ if (!isUniqueViolation(e))
137
+ throw e;
138
+ await db.table("wecom_app_profile").update(cols).where(scope).run();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 按企业代码找应用。`agentId` 为空时要求该企业只有一个启用中的应用。
144
+ * 「企业不存在」与「已停用」返回**同一条文案**(§5.3 枚举风险:不让外部探测哪些企业装过)。
145
+ */
146
+ export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps): Promise<AppProfile> {
147
+ const rows = await db.table("wecom_app_profile").select(["*"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
148
+ const active = rows.filter(r => String(r.status) === "active");
149
+ const picked = agentId ? active.filter(r => String(r.agent_id) === agentId) : active;
150
+ if (picked.length === 0)
151
+ fail(400, "企业代码无效或应用未开通");
152
+ if (picked.length > 1)
153
+ fail(400, "该企业开通了多个应用,登录链接必须带 agent 参数");
154
+ const r = picked[0]!;
155
+ return {
156
+ suiteId: String(r.suite_id),
157
+ corpId: String(r.corp_id),
158
+ authCorpId: r.auth_corp_id == null ? String(r.corp_id) : String(r.auth_corp_id),
159
+ agentId: String(r.agent_id),
160
+ env: r.env == null ? "prod" : String(r.env),
161
+ corpName: r.corp_name == null ? null : String(r.corp_name),
162
+ status: String(r.status) === "disabled" ? "disabled" : "active",
163
+ permanentCode: deps.decrypt(String(r.permanent_code_enc)),
164
+ };
165
+ }
166
+
167
+ /** 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计) */
168
+ export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps): Promise<number> {
169
+ return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
170
+ }
171
+
172
+ export async function putSuiteTicket(suiteId: string, ticket: string, expiresAt: number, deps: WecomStoreDeps): Promise<void> {
173
+ const rows = await db.table("wecom_suite_ticket").select(["suite_id"]).where({ field: "suite_id", op: "eq", value: suiteId }).all();
174
+ const cols = { suite_ticket: ticket, expires_at: expiresAt, update_time: deps.now() };
175
+ if (rows.length)
176
+ await db.table("wecom_suite_ticket").update(cols).where({ field: "suite_id", op: "eq", value: suiteId }).run();
177
+ else
178
+ await db.table("wecom_suite_ticket").insert({ suite_id: suiteId, ...cols }).run();
179
+ }
180
+
181
+ /** 套件票据(过期作废:过期后调 get_suite_token 会被企微拒,故本地先拒,省一次往返与一次失败计数) */
182
+ export async function getSuiteTicket(suiteId: string, deps: WecomStoreDeps): Promise<SuiteTicket> {
183
+ const rows = await db.table("wecom_suite_ticket").select(["suite_ticket", "expires_at"]).where({ field: "suite_id", op: "eq", value: suiteId }).all() as Array<Record<string, unknown>>;
184
+ const row = rows[0];
185
+ if (!row)
186
+ fail(503, "企微套件票据(suite_ticket)尚未收到:回调 URL 未打通或企微尚未推送");
187
+ const expiresAt = Number(row.expires_at);
188
+ if (!Number.isFinite(expiresAt) || expiresAt <= deps.now())
189
+ fail(503, "企微套件票据(suite_ticket)已过期:企微每 10 分钟推送一次,请检查回调 URL");
190
+ return { ticket: String(row.suite_ticket), expiresAt };
191
+ }
192
+
193
+ /** 一次性登录票据:只存 SHA-256,明文只出现在 302 的 URL 与 SPA 内存里 */
194
+ export async function issueLoginTicket(ticketHash: string, uid: number, ownerTenant: string, ttlSecs: number, deps: WecomStoreDeps): Promise<void> {
195
+ const now = deps.now();
196
+ // 顺手清过期票(共享表、无租户语义;避免长期累积;失败不影响签发)
197
+ await db.table("wecom_login_ticket").where({ field: "expires_at", op: "lt", value: now }).delete().run();
198
+ await db.table("wecom_login_ticket").insert({ ticket_hash: ticketHash, uid, owner_tenant: ownerTenant, expires_at: now + ttlSecs, used: 0, created_at: now }).run();
199
+ }
200
+
201
+ /**
202
+ * 兑换登录票据(原子单次消费):先 `update used=1 where used=0` 抢所有权——
203
+ * 抢不到说明已被用/伪造;抢到再校验过期。并发双花只可能有一个 changes=1。
204
+ */
205
+ export async function claimLoginTicket(ticketHash: string, deps: WecomStoreDeps): Promise<LoginTicketRow> {
206
+ const changed = Number(await db.table("wecom_login_ticket").update({ used: 1 }).where({
207
+ and: [
208
+ { field: "ticket_hash", op: "eq", value: ticketHash },
209
+ { field: "used", op: "eq", value: 0 },
210
+ ],
211
+ }).run());
212
+ if (changed !== 1)
213
+ fail(401, "登录票据无效或已使用");
214
+ const rows = await db.table("wecom_login_ticket").select(["uid", "owner_tenant", "expires_at"]).where({ field: "ticket_hash", op: "eq", value: ticketHash }).all() as Array<Record<string, unknown>>;
215
+ const row = rows[0];
216
+ if (!row)
217
+ fail(401, "登录票据无效或已使用");
218
+ if (Number(row.expires_at) <= deps.now())
219
+ fail(401, "登录票据已过期,请重新登录");
220
+ return { uid: Number(row.uid), ownerTenant: String(row.owner_tenant) };
221
+ }
222
+
223
+ /** 运行时依赖:密钥取自部署常量,加密原语取自 oj 运行时全局 */
224
+ export function runtimeStoreDeps(cfg: WecomConfig): WecomStoreDeps {
225
+ return {
226
+ encrypt: (plain: string) => crypto.aesGcmEncrypt(plain, cfg.encKey),
227
+ decrypt: (blob: string) => crypto.aesGcmDecrypt(blob, cfg.encKey),
228
+ now: () => Math.floor(Date.now() / 1000),
229
+ };
230
+ }
@@ -0,0 +1,36 @@
1
+ // 企微消息 XML 取字段(只读、白名单式)。
2
+ // 为什么不用通用 XML 解析:明文已由 AES-CBC + msg_signature 双重认证(见 crypt.ts),
3
+ // 这里只从**可信明文**里取几个扁平标签,且标签名由调用方常量给出——不解析结构、不处理命名空间,
4
+ // 用正则换掉一个解析器依赖与它的实体展开攻击面。标签名非纯字母直接抛错(防拼接正则)。
5
+ // 未命中返回 null(「没这个字段」与「字段为空」由调用方区分)。
6
+
7
+ const XML_ENTITIES: Record<string, string> = {
8
+ amp: "&",
9
+ lt: "<",
10
+ gt: ">",
11
+ quot: "\"",
12
+ apos: "'",
13
+ };
14
+
15
+ function decodeEntities(s: string): string {
16
+ return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (whole, body: string) => {
17
+ if (body[0] === "#") {
18
+ const code = body[1] === "x" || body[1] === "X"
19
+ ? Number.parseInt(body.slice(2), 16)
20
+ : Number.parseInt(body.slice(1), 10);
21
+ return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
22
+ }
23
+ return XML_ENTITIES[body.toLowerCase()] ?? whole;
24
+ });
25
+ }
26
+
27
+ /** 取 `<Name>值</Name>`(支持 CDATA 与实体);未命中 null */
28
+ export function xmlField(xml: string, name: string): string | null {
29
+ if (!/^[A-Z_]\w*$/i.test(name))
30
+ throw new Error(`xml: illegal field name ${name}`);
31
+ const re = new RegExp(`<${name}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))</${name}>`);
32
+ const m = re.exec(xml);
33
+ if (!m)
34
+ return null;
35
+ return decodeEntities((m[1] ?? m[2] ?? "").trim());
36
+ }
@@ -1,6 +1,12 @@
1
1
  name: auth
2
2
  desc: JWT 鉴权端点(业务路由):login/refresh/logout;Bearer 守卫由 oj-auth 提供
3
3
  version: 0.1.0
4
+ # 企微代开发 connector 的三张表(schema.yaml 定性 + migrations/0001__wecom* 建表):
5
+ # 共享表,只放 corp→租户映射 / 套件凭证 / 一次性登录票据,不放租户业务数据。
6
+ tables:
7
+ - wecom_app_profile
8
+ - wecom_suite_ticket
9
+ - wecom_login_ticket
4
10
  deps: # 两处都用:login/refresh 读 _platform.users(ownership_guard: deny,§3/S003);
5
11
  # 跨模块别名 `#/_platform/…` 的依赖门禁(oj build S008)
6
12
  _platform: ^0.1.0
@@ -0,0 +1,35 @@
1
+ -- 企微代开发三表 mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:TEXT 主键须给长度(按业务长度收窄为 VARCHAR);INTEGER → BIGINT(秒级 epoch);
3
+ -- 复合主键里 VARCHAR 合计长度须可进 InnoDB 索引;索引写在 CREATE TABLE 内(MySQL 无 CREATE INDEX IF NOT EXISTS)。
4
+ CREATE TABLE IF NOT EXISTS wecom_app_profile (
5
+ suite_id VARCHAR(64) NOT NULL,
6
+ corp_id VARCHAR(64) NOT NULL,
7
+ agent_id VARCHAR(32) NOT NULL,
8
+ auth_corp_id VARCHAR(64) NOT NULL,
9
+ env VARCHAR(32) NOT NULL DEFAULT 'prod',
10
+ corp_name VARCHAR(255),
11
+ status VARCHAR(16) NOT NULL DEFAULT 'active',
12
+ permanent_code_enc TEXT NOT NULL,
13
+ installed_at BIGINT NOT NULL,
14
+ update_time BIGINT NOT NULL,
15
+ PRIMARY KEY (suite_id, corp_id, agent_id)
16
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
17
+
18
+ CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
19
+ suite_id VARCHAR(64) NOT NULL,
20
+ suite_ticket TEXT NOT NULL,
21
+ expires_at BIGINT NOT NULL,
22
+ update_time BIGINT NOT NULL,
23
+ PRIMARY KEY (suite_id)
24
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
25
+
26
+ CREATE TABLE IF NOT EXISTS wecom_login_ticket (
27
+ ticket_hash CHAR(64) NOT NULL,
28
+ uid BIGINT NOT NULL,
29
+ owner_tenant VARCHAR(128) NOT NULL,
30
+ expires_at BIGINT NOT NULL,
31
+ used INTEGER NOT NULL DEFAULT 0,
32
+ created_at BIGINT NOT NULL,
33
+ PRIMARY KEY (ticket_hash),
34
+ KEY idx_wecom_login_ticket_expires (expires_at)
35
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,33 @@
1
+ -- 企微代开发三表 pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:INTEGER → BIGINT(秒级 epoch);TEXT 在 pg 合法,主键/密文列保留 TEXT。
3
+ CREATE TABLE IF NOT EXISTS wecom_app_profile (
4
+ suite_id TEXT NOT NULL,
5
+ corp_id TEXT NOT NULL,
6
+ agent_id TEXT NOT NULL,
7
+ auth_corp_id TEXT NOT NULL,
8
+ env TEXT NOT NULL DEFAULT 'prod',
9
+ corp_name TEXT,
10
+ status TEXT NOT NULL DEFAULT 'active',
11
+ permanent_code_enc TEXT NOT NULL,
12
+ installed_at BIGINT NOT NULL,
13
+ update_time BIGINT NOT NULL,
14
+ PRIMARY KEY (suite_id, corp_id, agent_id)
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
18
+ suite_id TEXT NOT NULL PRIMARY KEY,
19
+ suite_ticket TEXT NOT NULL,
20
+ expires_at BIGINT NOT NULL,
21
+ update_time BIGINT NOT NULL
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS wecom_login_ticket (
25
+ ticket_hash TEXT NOT NULL PRIMARY KEY,
26
+ uid BIGINT NOT NULL,
27
+ owner_tenant TEXT NOT NULL,
28
+ expires_at BIGINT NOT NULL,
29
+ used INTEGER NOT NULL DEFAULT 0,
30
+ created_at BIGINT NOT NULL
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_wecom_login_ticket_expires ON wecom_login_ticket (expires_at);
@@ -0,0 +1,47 @@
1
+ -- 企微代开发 connector 的三张表(阶段 P1)。结构只进 migrations(S006),列集与 schema.yaml 一致。
2
+ --
3
+ -- 定性(三处必须一致:本文件头 / schema.yaml `tenant: false` / config.yaml `tenant.shared_allow`):
4
+ -- 三张表全部**共享表**——它们在「还没有租户上下文」的时刻被读写:
5
+ -- · wecom_app_profile 本身就是 corp → 租户的映射权威(查它的时候必然不知道租户);
6
+ -- · wecom_suite_ticket 是套件级凭证,按 suite_id 全局唯一;
7
+ -- · wecom_login_ticket 跨「匿名 302 回跳」与「已认证换取」两段,票据本身携带租户。
8
+ -- 共享 = 不受 sql_guard 约束,故只放映射/凭证/一次性票据,不放任何租户业务数据。
9
+ --
10
+ -- 凭证保密(§7.5):permanent_code 即代开发应用的 secret(具备全 corp 数据权限),
11
+ -- 只以 AES-GCM 密文落库(crypto.aesGcmEncrypt,密钥取部署常量 wecom_enc_key),列名带 _enc 后缀。
12
+ CREATE TABLE IF NOT EXISTS wecom_app_profile (
13
+ suite_id TEXT NOT NULL,
14
+ corp_id TEXT NOT NULL,
15
+ agent_id TEXT NOT NULL,
16
+ auth_corp_id TEXT NOT NULL,
17
+ env TEXT NOT NULL DEFAULT 'prod',
18
+ corp_name TEXT,
19
+ status TEXT NOT NULL DEFAULT 'active',
20
+ permanent_code_enc TEXT NOT NULL,
21
+ installed_at INTEGER NOT NULL,
22
+ update_time INTEGER NOT NULL,
23
+ PRIMARY KEY (suite_id, corp_id, agent_id)
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
27
+ suite_id TEXT NOT NULL PRIMARY KEY,
28
+ suite_ticket TEXT NOT NULL,
29
+ expires_at INTEGER NOT NULL,
30
+ update_time INTEGER NOT NULL
31
+ );
32
+
33
+ -- 一次性登录票据:302 的 URL 里只能放票,不能放 access/refresh token(会被浏览器历史、
34
+ -- Referer、反代日志留存)。只存 SHA-256,明文仅存在于 302 Location 与 SPA 内存。
35
+ -- 列名用 owner_tenant 而不是 tenant_id:本表是共享表,出现 tenant_id 列会被
36
+ -- 「tenant:false 却声明隔离列」的定性红线判为自相矛盾,而它的语义是「票据绑定的租户」。
37
+ CREATE TABLE IF NOT EXISTS wecom_login_ticket (
38
+ ticket_hash TEXT NOT NULL PRIMARY KEY,
39
+ uid INTEGER NOT NULL,
40
+ owner_tenant TEXT NOT NULL,
41
+ expires_at INTEGER NOT NULL,
42
+ used INTEGER NOT NULL DEFAULT 0,
43
+ created_at INTEGER NOT NULL
44
+ );
45
+
46
+ -- 过期票清理按 expires_at 扫(签发时顺手删);无索引会全表扫
47
+ CREATE INDEX IF NOT EXISTS idx_wecom_login_ticket_expires ON wecom_login_ticket (expires_at);
@@ -0,0 +1,48 @@
1
+ # 声明式表结构(§4.2 / D1-C):auth 模块自有表——企微代开发 connector 的三张表。
2
+ # 结构真源是 migrations/0001__wecom*.sql(S006);本文件负责「列集/主键/租户定性」的运行时元数据,
3
+ # 与迁移逐列对齐(列集不一致时 oj 的 schema 校验会 fail-fast)。
4
+ #
5
+ # 定性(三处一致:这里 / config.yaml `tenant.shared_allow` / 迁移文件头注释):
6
+ # 三张表都是**共享表**(`tenant: false`)——它们在「还没有租户上下文」的时刻被读写,
7
+ # 而且本身就是 corp → 租户的映射权威。只放映射/凭证/一次性票据,不放租户业务数据。
8
+ tables:
9
+ wecom_app_profile:
10
+ # 共享表:corp → 租户的映射权威,查它时必然还不知道租户(D9)
11
+ tenant: false
12
+ pk: [suite_id, corp_id, agent_id]
13
+ columns:
14
+ suite_id: {type: text, null: false}
15
+ corp_id: {type: text, null: false}
16
+ agent_id: {type: text, null: false}
17
+ # 企微事件里的 AuthCorpId:第三方应用体系下可能是 open_corpid,故与 corp_id 分列存
18
+ auth_corp_id: {type: text, null: false}
19
+ # 测试/正式等部署标签(D5:只作标签,不参与主键)
20
+ env: {type: text, null: false}
21
+ corp_name: {type: text}
22
+ # active / disabled:取消授权只改状态不删行(留痕)
23
+ status: {type: text, null: false}
24
+ # permanent_code(= 代开发应用的 secret)的 AES-GCM 密文,禁止明文落库
25
+ permanent_code_enc: {type: text, null: false}
26
+ installed_at: {type: integer, null: false}
27
+ update_time: {type: integer, null: false}
28
+ wecom_suite_ticket:
29
+ # 共享表:套件级凭证,按 suite_id 全局唯一(每 10 分钟被企微覆盖一次)
30
+ tenant: false
31
+ pk: suite_id
32
+ columns:
33
+ suite_id: {type: text, null: false}
34
+ suite_ticket: {type: text, null: false}
35
+ expires_at: {type: integer, null: false}
36
+ update_time: {type: integer, null: false}
37
+ wecom_login_ticket:
38
+ # 共享表:一次性登录票据,跨「匿名 302 回跳」与「已认证换取」两段
39
+ tenant: false
40
+ pk: ticket_hash
41
+ columns:
42
+ ticket_hash: {type: text, null: false}
43
+ uid: {type: integer, null: false}
44
+ # 票据绑定的租户。不叫 tenant_id:共享表出现隔离列会被定性红线判为自相矛盾
45
+ owner_tenant: {type: text, null: false}
46
+ expires_at: {type: integer, null: false}
47
+ used: {type: integer, null: false}
48
+ created_at: {type: integer, null: false}
@@ -0,0 +1,121 @@
1
+ // 代开发后台(服务商运营面):GET /auth/wecom/admin/apps · POST /auth/wecom/admin/status · GET /auth/wecom/admin/install
2
+ //
3
+ // 权限:仅超级管理员(`assertSuperAdmin`)。这三件事都是**跨租户**的站点级操作
4
+ // (看所有已授权企业、停用某企业、生成安装链接),不是某个租户的管理员能做的;
5
+ // 用 `assertSystemAdmin` 会把 admin 也放进来,而 admin 属于某个租户,等于让他看别家企业的 corp_id。
6
+ //
7
+ // 三个端点都不挂在 `tenant.anonymous_paths` 里:它们是正常登录后的管理面,必须带租户头,
8
+ // 也就不会走 `db.asTenant`(见 _shared/wecom/admin.ts 头注释第 2 点)。
9
+ import { respond } from "#/_platform/_shared/respond";
10
+ import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
11
+ import { recordOp } from "#/_platform/_shared/service/infra/audit";
12
+ import { createInstall, listApps, setAppStatus } from "../../_shared/wecom/admin";
13
+ import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
14
+ import { readWecomConfig } from "../../_shared/wecom/config";
15
+ import { runtimeStoreDeps } from "../../_shared/wecom/store";
16
+
17
+ /** 清单:已授权企业 × 应用(不含 permanent_code,secret 不出库) */
18
+ async function get() {
19
+ await respond(async () => {
20
+ await assertSuperAdmin();
21
+ return listApps();
22
+ });
23
+ }
24
+ get.response = {
25
+ type: "array",
26
+ items: {
27
+ type: "object",
28
+ required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "installed_at", "update_time"],
29
+ properties: {
30
+ suite_id: { type: "string" },
31
+ corp_id: { type: "string" },
32
+ agent_id: { type: "string" },
33
+ auth_corp_id: { type: "string" },
34
+ env: { type: "string" },
35
+ corp_name: { type: ["string", "null"] },
36
+ status: { type: "string", enum: ["active", "disabled"] },
37
+ installed_at: { type: "number" },
38
+ update_time: { type: "number" },
39
+ },
40
+ },
41
+ };
42
+
43
+ const status = {
44
+ // 启停某个企业的一个应用。停用是安全动作:企业整体停用时同步吊销其会话(见 admin.ts)。
45
+ async post() {
46
+ await respond(async () => {
47
+ await assertSuperAdmin();
48
+ const body = (http.body ?? {}) as { corpId?: unknown, agentId?: unknown, status?: unknown };
49
+ const corpId = String(body.corpId ?? "").trim();
50
+ const agentId = String(body.agentId ?? "").trim();
51
+ const next = String(body.status ?? "").trim();
52
+ if (!corpId || !agentId)
53
+ throw Object.assign(new Error("缺少企业代码或应用 id"), { status: 400 });
54
+ if (next !== "active" && next !== "disabled")
55
+ throw Object.assign(new Error("status 只能是 active 或 disabled"), { status: 400 });
56
+
57
+ const result = await setAppStatus(corpId, agentId, next, {
58
+ client: createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
59
+ // 只有 update_time 用到 now,加解密用不上:这里给空实现,避免为一个时间戳去读部署常量
60
+ store: { encrypt: () => "", decrypt: () => "", now: () => Math.floor(Date.now() / 1000) },
61
+ });
62
+ await recordOp({
63
+ action: next === "active" ? "wecom.admin.enable" : "wecom.admin.disable",
64
+ module: "auth",
65
+ target: corpId,
66
+ detail: { agentId, revokedSessions: result.revokedSessions },
67
+ // 停用是断人访问的动作,按安全事件留痕(与 cancel_auth 同级)
68
+ level: next === "active" ? 2 : 4,
69
+ });
70
+ return result;
71
+ });
72
+ },
73
+ };
74
+ status.post.schema = {
75
+ body: {
76
+ type: "object",
77
+ required: ["corpId", "agentId", "status"],
78
+ properties: {
79
+ corpId: { type: "string", minLength: 1 },
80
+ agentId: { type: "string", minLength: 1 },
81
+ status: { type: "string", enum: ["active", "disabled"] },
82
+ },
83
+ },
84
+ };
85
+ status.post.response = {
86
+ type: "object",
87
+ required: ["updated", "revokedSessions"],
88
+ properties: {
89
+ updated: { type: "number" },
90
+ revokedSessions: { type: "number" },
91
+ },
92
+ };
93
+
94
+ const install = {
95
+ // 开通入口:生成 install URL(预授权码 10 分钟有效,故不缓存,每次点都重新换)。
96
+ // 顺带回「指令回调 URL」——服务商后台要把它逐字填进企微「代开发」配置页。
97
+ async get() {
98
+ await respond(async () => {
99
+ await assertSuperAdmin();
100
+ const cfg = readWecomConfig();
101
+ const state = String((http.query as Record<string, unknown>)?.state ?? "").trim();
102
+ return createInstall(
103
+ cfg,
104
+ createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
105
+ runtimeStoreDeps(cfg),
106
+ state,
107
+ );
108
+ });
109
+ },
110
+ };
111
+ install.get.response = {
112
+ type: "object",
113
+ required: ["installUrl", "callbackUrl", "expiresIn"],
114
+ properties: {
115
+ installUrl: { type: "string" },
116
+ callbackUrl: { type: "string" },
117
+ expiresIn: { type: "number" },
118
+ },
119
+ };
120
+
121
+ export default { apps: { get }, status, install };