@oj-module/cli 0.2.49 → 0.2.53

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 (75) hide show
  1. package/devkit/README.md +8 -6
  2. package/devkit/SKILL.md +32 -12
  3. package/devkit/manual.md +6 -3
  4. package/devkit/wecom-daikaifa.md +95 -1
  5. package/devkit/wecom-internal-app.md +750 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
  8. package/shell-dist/assets/runtime.js +24 -3
  9. package/shell-dist/index.html +9 -12
  10. package/shell-dist/versions.json +3 -3
  11. package/src/build.ts +19 -2
  12. package/src/init.ts +5 -4
  13. package/src/shared-deps.ts +27 -9
  14. package/src/site-base.ts +1 -1
  15. package/templates/config.yaml +28 -6
  16. package/templates/init.include.yaml +4 -7
  17. package/templates/openapi.json +2636 -2356
  18. package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
  19. package/templates/src/auth/api/_shared/wecom/admin.ts +91 -7
  20. package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
  21. package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
  22. package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
  23. package/templates/src/auth/api/_shared/wecom/events.ts +28 -6
  24. package/templates/src/auth/api/_shared/wecom/login.ts +21 -13
  25. package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
  26. package/templates/src/auth/api/_shared/wecom/sync.ts +11 -1
  27. package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
  28. package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
  29. package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
  30. package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
  31. package/templates/src/auth/api/schema.yaml +2 -0
  32. package/templates/src/auth/api/wecom/admin/api.ts +112 -11
  33. package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
  34. package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
  35. package/templates/src/auth/api/wecom/api.ts +102 -48
  36. package/templates/src/auth/api/wecom/sync/api.ts +21 -5
  37. package/templates/src/auth/web/__client/api.schemas.ts +22 -1
  38. package/templates/src/auth/web/__client/api.ts +47 -1
  39. package/templates/src/login/web/pages/login.tsx +54 -24
  40. package/templates/src/system/api/settings/api.ts +3 -3
  41. package/templates/src/system/web/entry.ts +4 -4
  42. package/templates/src/system/web/locales/en-US.json +38 -1
  43. package/templates/src/system/web/locales/zh-CN.json +38 -1
  44. package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
  45. package/templates/src/system/web/pages/users/index.tsx +7 -4
  46. package/templates/src/system/web/pages/users/roles.tsx +14 -3
  47. package/templates/src/system/web/pages/wecom/index.tsx +292 -45
  48. package/templates/tests/wecom-admin.spec.ts +7 -7
  49. package/templates/tests/wecom-connector.spec.ts +7 -6
  50. package/templates/tests/wecom-corp.spec.ts +523 -0
  51. package/templates/tests/wecom-endpoints.spec.ts +10 -4
  52. package/templates/tests/wecom-org-sync.spec.ts +7 -1
  53. package/templates/tsconfig.paths.json +0 -1
  54. package/templates/web.config.ts +1 -2
  55. package/vendor/host-versions.json +3 -3
  56. package/shell-dist/assets/echarts-charts.js +0 -58571
  57. package/shell-dist/assets/echarts-features.js +0 -24057
  58. package/shell-dist/assets/echarts-for-react.js +0 -704
  59. package/shell-dist/assets/echarts.js +0 -82916
  60. package/shell-dist/assets/editor.js +0 -161457
  61. package/shell-dist/assets/vditor-index-css.js +0 -7
  62. package/shell-dist/assets/vditor.js +0 -15223
  63. package/templates/src/demo/api/manifest.yaml +0 -5
  64. package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
  65. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
  66. package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
  67. package/templates/src/demo/api/schema.yaml +0 -12
  68. package/templates/src/demo/api/seed.sql +0 -5
  69. package/templates/src/demo/api/todos/api.ts +0 -59
  70. package/templates/src/demo/web/__client/api.schemas.ts +0 -20
  71. package/templates/src/demo/web/__client/api.ts +0 -89
  72. package/templates/src/demo/web/entry.ts +0 -42
  73. package/templates/src/demo/web/locales/en-US.json +0 -10
  74. package/templates/src/demo/web/locales/zh-CN.json +0 -10
  75. package/templates/src/demo/web/pages/index.tsx +0 -45
@@ -21,6 +21,7 @@ import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
21
21
  import { recordOp } from "#/_platform/_shared/service/infra/audit";
22
22
  import { handleContactChange } from "./contacts";
23
23
  import { callbackFields, field, requireField } from "./payload";
24
+ import { provisionTenantAdmin } from "./tenant-admin";
24
25
  import { corpScope, ensureTenant, findCorpAuth, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setAuthMode, setProfileStatus, tenantOfCorp } from "./store";
25
26
 
26
27
  /** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
@@ -59,16 +60,32 @@ async function auditAuth(action: string, corpId: string, detail?: unknown, level
59
60
  }
60
61
  }
61
62
 
62
- /** 该 corp 的所有应用都标记为停用,并吊销其租户下的全部会话 */
63
+ /** 该 corp 的代开发应用全部标记为停用;**只有该企业再没有启用中的应用时才吊销会话 */
63
64
  async function disableCorp(corpId: string, deps: SuiteEventDeps): Promise<void> {
64
65
  // 取消授权同样是匿名回调:会话清理走裸 SQL,`sql_guard: deny` 下没有租户上下文会被拒——
65
66
  // 而这里拒掉的后果是「旧 refresh 串仍能续命」,属安全红线,故必须绑成功再往下走。
66
67
  ensureTenant(tenantOfCorp(corpId));
67
- const rows = await db.table("wecom_app_profile").select(["agent_id"]).where(corpScope(corpId)).all() as Array<{ agent_id: string }>;
68
+ // 只取 suite 行:共存部署里同一家企业可能还有自建应用,cancel_auth 与它无关(C4)
69
+ const rows = await db.table("wecom_app_profile").select(["agent_id"]).where({
70
+ and: [corpScope(corpId), { field: "credential_kind", op: "eq", value: "suite" }],
71
+ }).all() as Array<{ agent_id: string }>;
68
72
  for (const r of rows)
69
73
  await deps.client.invalidateCorpToken(corpId, String(r.agent_id));
70
- await setProfileStatus(corpId, "disabled", deps.store);
71
- // 会话必须与授权同频:解绑后旧 refresh 串不得继续换新 access token。
74
+ await setProfileStatus(corpId, "disabled", deps.store, "suite");
75
+ await revokeOrKeep(corpId);
76
+ }
77
+
78
+ /**
79
+ * 会话与授权同频:解绑后旧 refresh 串不得继续换新 access token。
80
+ * 但「还有别的应用在用」时不吊销(同 `admin.ts::setAppStatus` 的语义)——否则取消代开发授权
81
+ * 会把同一家企业自建应用的在线用户一起踢下线。
82
+ */
83
+ async function revokeOrKeep(corpId: string): Promise<void> {
84
+ const still = await db.table("wecom_app_profile").select(["agent_id"]).where({
85
+ and: [corpScope(corpId), { field: "status", op: "eq", value: "active" }],
86
+ }).all();
87
+ if (still.length)
88
+ return;
72
89
  // tenant 条件写死在 SQL 里(不用 truncate:它的清理范围取决于本请求是否已绑过租户),
73
90
  // 取消授权回调是匿名端点,不该依赖那种隐式前提。
74
91
  await revokeTenantSessions(tenantOfCorp(corpId));
@@ -93,12 +110,16 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
93
110
  agentId,
94
111
  env,
95
112
  corpName: granted.corpName,
113
+ // 来源由这一条路径决定:能走到 create_auth 的只可能是代开发
114
+ credentialKind: "suite",
96
115
  permanentCode: granted.permanentCode,
97
116
  authMode: granted.authModes[agentId] ?? 0,
98
117
  }, store);
99
118
  // secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
100
119
  await client.invalidateCorpToken(granted.corpId, agentId);
101
120
  }
121
+ // 租户具象化:代开发企业授权成功即配一个本租户管理员(尽力而为,绝不拖垮 1 秒回调窗口)
122
+ await provisionTenantAdmin(granted.corpId);
102
123
  await auditAuth("wecom.auth.apply", granted.corpId, { agentIds: granted.agentIds, env });
103
124
  return { corpId: granted.corpId, agentIds: granted.agentIds };
104
125
  }
@@ -112,7 +133,8 @@ async function applyAuthCode(authCode: string, state: string | null, deps: Suite
112
133
  */
113
134
  async function refreshAuthModes(corpId: string, deps: SuiteEventDeps): Promise<void> {
114
135
  try {
115
- const auth = await findCorpAuth(corpId, deps.store);
136
+ // 只对 suite 行成立:自建应用没有「授权模式」这个概念,给它刷 auth_mode 只会写脏数据(C5)
137
+ const auth = await findCorpAuth(corpId, deps.store, "suite");
116
138
  if (!auth)
117
139
  return;
118
140
  const ticket = await getSuiteTicket(deps.cfg.suiteId, deps.store);
@@ -147,7 +169,7 @@ export async function handleSuiteEvent(payload: unknown, deps: SuiteEventDeps):
147
169
  // 落进 default 只会被记成「不消费的事件」。
148
170
  // 与下面几个分支的不同:通讯录事件失败**不抛**(事件是加速器,重推补不回来,见 contacts.ts)。
149
171
  if (field(fields, "Event") === "change_contact" || infoType === "change_contact")
150
- return await handleContactChange(fields, deps);
172
+ return await handleContactChange(fields, { client: deps.client, store: deps.store, credentialKind: "suite" });
151
173
 
152
174
  switch (infoType) {
153
175
  case "suite_ticket": {
@@ -62,32 +62,40 @@ export function buildAuthorizeUrl(opts: {
62
62
  /**
63
63
  * 代开发模板常只服务一家企业:登录入口不传 corp 时,从已安装档案自动解析,
64
64
  * 让「应用主页」直接填 https://<域名>/login 即可(无需 ${corpid} 占位符,避开企微后台对非法 URL 字符的校验)。
65
- * agent 未指定且该企业多应用时,取首个活跃应用以 corp 维度授权。
65
+ *
66
+ * `self` = 本部署自己那家企业内部应用(`wecom_corp_*` 配了什么)。
67
+ * 共存部署里必须传:否则别家企业通过代开发装进来的档案会把「本站默认登录哪家企业」淹掉,
68
+ * 本站自己的 `/login` 反倒回 400「已授权多家企业」。传了就按「自己的企业优先」解析,
69
+ * 且默认 agent 也来自配置,不再依赖写死的应用 id。
66
70
  */
67
- // 未指定 agent 且多应用时,优先作为默认登录应用的 agent(按部署需要调整)
68
- const DEFAULT_WECOM_AGENT = "1000059";
71
+ export interface SelfApp {
72
+ corpId: string
73
+ agentId: string
74
+ }
69
75
 
70
- export async function resolveWecomProfile(agentId: string | null): Promise<{ corpId: string, agentId: string, env: string }> {
76
+ export async function resolveWecomProfile(agentId: string | null, self?: SelfApp): Promise<{ corpId: string, agentId: string, env: string }> {
71
77
  const rows = await db.table("wecom_app_profile")
72
78
  .select(["corp_id", "agent_id", "env", "status"])
73
79
  .where({ field: "status", op: "eq", value: "active" })
74
80
  .all() as Array<{ corp_id: string, agent_id: string, env: string | null, status: string }>;
75
81
  const active = rows.filter(r => String(r.status) === "active");
76
82
  if (!active.length)
77
- throw Object.assign(new Error("尚未完成企业微信授权(无已安装应用档案),请先走「生成开通链接」完成授权"), { status: 400 });
78
- const corps = [...new Set(active.map(r => r.corp_id))];
83
+ throw Object.assign(new Error("尚未完成企业微信接入(无可用应用档案):代开发请先扫码开通;企业内部应用请在后台点「验证并接入」"), { status: 400 });
84
+ const pool = self ? active.filter(r => String(r.corp_id) === self.corpId) : active;
85
+ if (!pool.length)
86
+ throw Object.assign(new Error("本次部署配置的企业内部应用尚未接入:请在后台点「验证并接入」"), { status: 400 });
87
+ const corps = [...new Set(pool.map(r => String(r.corp_id)))];
79
88
  if (corps.length > 1 && !agentId)
80
89
  throw Object.assign(new Error("已授权多家企业,请在登录链接带 corp 参数以区分"), { status: 400 });
81
- const corpId = corps[0]!;
82
- const agents = active.filter(r => r.corp_id === corpId);
83
- // 指定 agent 就必须命中,未指定则退到默认 agent / 首个活跃应用(agents 非空:active 已非空且
84
- // 至少有一个属于 corpId——corps 就是按 active 的 corp_id 去重出来的)
90
+ const corpId = self ? self.corpId : corps[0]!;
91
+ const agents = pool.filter(r => String(r.corp_id) === corpId);
92
+ // 指定 agent 就必须命中;未指定时:自建应用优先取配置里的 agent,否则取首个活跃应用
85
93
  const pick = agentId
86
- ? agents.find(a => a.agent_id === agentId)
87
- : (agents.find(a => a.agent_id === DEFAULT_WECOM_AGENT) ?? agents[0]);
94
+ ? agents.find(a => String(a.agent_id) === agentId)
95
+ : (self ? agents.find(a => String(a.agent_id) === self.agentId) : undefined) ?? agents[0];
88
96
  if (!pick)
89
97
  throw Object.assign(new Error("指定的 agent 在该企业中不存在"), { status: 400 });
90
- return { corpId: pick.corp_id, agentId: pick.agent_id, env: pick.env == null ? "prod" : String(pick.env) };
98
+ return { corpId: String(pick.corp_id), agentId: String(pick.agent_id), env: pick.env == null ? "prod" : String(pick.env) };
91
99
  }
92
100
 
93
101
  export async function putLoginState(nonce: string, pending: PendingLogin, cache: WecomCache): Promise<void> {
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // permanent_code(= 代开发应用的 secret,全 corp 数据权限)只以 AES-GCM 密文落库(§7.5),
9
9
  // 加解密经 deps 注入:Node 侧测试用假实现,运行时走 crypto.aesGcmEncrypt/Decrypt。
10
- import type { WecomConfig } from "./config";
10
+ import type { WecomBase, WecomKind } from "./config";
11
11
 
12
12
  export interface WecomStoreDeps {
13
13
  encrypt: (plain: string) => string
@@ -26,6 +26,13 @@ export interface AppProfile {
26
26
  env: string
27
27
  corpName: string | null
28
28
  status: "active" | "disabled"
29
+ /**
30
+ * 这一行的凭据来源(0003 列)。**不是标签,是语义**:
31
+ * · suite 行由 create_auth 写入,permanent_code 里是代开发换来的 secret;
32
+ * · corp 行由「验证并接入」写入,里面是应用详情页的 Secret(suite_id 为空串)。
33
+ * 同一 corp_id 两行共存时,靠它决定「取消代开发授权该停哪一行」。
34
+ */
35
+ credentialKind: WecomKind
29
36
  permanentCode: string
30
37
  /** 0=管理员授权 / 1=成员授权(成员授权下应用无任何部门权限,组织架构同步必然 60011,§2.3) */
31
38
  authMode: number
@@ -92,6 +99,30 @@ export function corpAgentScope(corpId: string, agentId: string) {
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * 企业范围 + 可选的来源过滤。
104
+ *
105
+ * 共存部署里**同一家**企业可能同时有代开发行与自建行(迁移期最常见),而「取消代开发授权」
106
+ * 「复核授权模式」「找 secret」这三件事都只对 suite 行成立。不传 kind = 不过滤(保持
107
+ * 单来源部署的原行为,`suite_id` 主键天然只有一类行)。
108
+ */
109
+ function corpKindScope(corpId: string, kind?: WecomKind) {
110
+ const base = corpScope(corpId);
111
+ if (!kind)
112
+ return base;
113
+ return {
114
+ and: [
115
+ base,
116
+ { field: "credential_kind", op: "eq", value: kind },
117
+ ],
118
+ };
119
+ }
120
+
121
+ /** 行 → 来源(库里的列是 text;只有明确写了 corp 才算自建,其余按代开发) */
122
+ function kindOfRow(r: Record<string, unknown>): WecomKind {
123
+ return String(r.credential_kind) === "corp" ? "corp" : "suite";
124
+ }
125
+
95
126
  /**
96
127
  * 吊销某租户的全部会话(refresh token)。
97
128
  *
@@ -121,6 +152,9 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
121
152
  env: p.env,
122
153
  corp_name: p.corpName,
123
154
  status: p.status ?? "active",
155
+ // 来源同时落在两处:主键里的 suite_id(corp 行为空串)与这一列(可读、可过滤)。
156
+ // 更新路径也写:存量 suite 行被 corp 接入覆盖时(人工导错)不该留着旧标注。
157
+ credential_kind: p.credentialKind,
124
158
  permanent_code_enc: deps.encrypt(p.permanentCode),
125
159
  // 授权模式是「该企业应用能不能做组织架构同步」的判据(§2.3);重授权时必须跟着刷新
126
160
  auth_mode: Number(p.authMode ?? 0),
@@ -146,9 +180,12 @@ export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: App
146
180
  /**
147
181
  * 按企业代码找应用。`agentId` 为空时要求该企业只有一个启用中的应用。
148
182
  * 「企业不存在」与「已停用」返回**同一条文案**(§5.3 枚举风险:不让外部探测哪些企业装过)。
183
+ *
184
+ * `agentId` 已唯一确定一行(主键第三段),所以 `kind` 只是**加一层保险**:
185
+ * 事件路径知道自己验签过的是哪套凭据,带上它就不会在同名 agent 撞车时取错 secret。
149
186
  */
150
- export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps): Promise<AppProfile> {
151
- const rows = await db.table("wecom_app_profile").select(["*"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
187
+ export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps, kind?: WecomKind): Promise<AppProfile> {
188
+ const rows = await db.table("wecom_app_profile").select(["*"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
152
189
  const active = rows.filter(r => String(r.status) === "active");
153
190
  const picked = agentId ? active.filter(r => String(r.agent_id) === agentId) : active;
154
191
  if (picked.length === 0)
@@ -157,13 +194,14 @@ export async function findProfile(corpId: string, agentId: string | null, deps:
157
194
  fail(400, "该企业开通了多个应用,登录链接必须带 agent 参数");
158
195
  const r = picked[0]!;
159
196
  return {
160
- suiteId: String(r.suite_id),
197
+ suiteId: String(r.suite_id ?? ""),
161
198
  corpId: String(r.corp_id),
162
199
  authCorpId: r.auth_corp_id == null ? String(r.corp_id) : String(r.auth_corp_id),
163
200
  agentId: String(r.agent_id),
164
201
  env: r.env == null ? "prod" : String(r.env),
165
202
  corpName: r.corp_name == null ? null : String(r.corp_name),
166
203
  status: String(r.status) === "disabled" ? "disabled" : "active",
204
+ credentialKind: kindOfRow(r),
167
205
  permanentCode: deps.decrypt(String(r.permanent_code_enc)),
168
206
  authMode: Number(r.auth_mode ?? 0),
169
207
  };
@@ -174,6 +212,8 @@ export interface ProfileProbe {
174
212
  agentId: string
175
213
  authMode: number
176
214
  status: AppProfile["status"]
215
+ /** 来源:corp 行没有「授权模式」概念,页面不该给它画成员授权卡片 */
216
+ credentialKind: WecomKind
177
217
  }
178
218
 
179
219
  /**
@@ -186,8 +226,8 @@ export interface ProfileProbe {
186
226
  * 选中口径与 `findProfile` 对齐(优选启用中的应用;全停用时退回停用行,让页面能画
187
227
  * 「已取消授权」冻结态),只多一条:没有启用应用时也返回,而不是 400。
188
228
  */
189
- export async function probeProfile(corpId: string, agentId: string | null): Promise<ProfileProbe | null> {
190
- const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
229
+ export async function probeProfile(corpId: string, agentId: string | null, kind?: WecomKind): Promise<ProfileProbe | null> {
230
+ const rows = await db.table("wecom_app_profile").select(["agent_id", "auth_mode", "status", "credential_kind"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
191
231
  if (rows.length === 0)
192
232
  return null;
193
233
  const active = rows.filter(r => String(r.status) === "active");
@@ -202,12 +242,17 @@ export async function probeProfile(corpId: string, agentId: string | null): Prom
202
242
  agentId: String(r.agent_id),
203
243
  authMode: Number(r.auth_mode ?? 0),
204
244
  status: String(r.status) === "disabled" ? "disabled" : "active",
245
+ credentialKind: kindOfRow(r),
205
246
  };
206
247
  }
207
248
 
208
- /** 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计) */
209
- export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps): Promise<number> {
210
- return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
249
+ /**
250
+ * 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计)。
251
+ * `kind` 缺省 = 该企业的全部行(单来源部署的原行为);传 `suite` 才是 cancel_auth 该做的——
252
+ * 代开发被取消授权时不能顺手把同一家企业的自建应用一起停掉。
253
+ */
254
+ export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps, kind?: WecomKind): Promise<number> {
255
+ return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpKindScope(corpId, kind)).run());
211
256
  }
212
257
 
213
258
  /**
@@ -217,15 +262,19 @@ export async function setProfileStatus(corpId: string, status: AppProfile["statu
217
262
  *
218
263
  * 一并返回 `corpId`(真实企业 ID):事件里给的可能是 `AuthCorpId`(第三方体系下是 open_corpid),
219
264
  * 而租户键只能用真实 corp_id(D10),故由这里统一把「事件身份 → 租户身份」的翻译交出来。
265
+ *
266
+ * `kind` 由**事件入口**传下来(本次验签命中的是哪套凭据):同一家企业双应用时,
267
+ * 取错 kind 就会拿另一套 secret 去换 token,症状是 40001 而不是「配置错了」。
220
268
  */
221
- export async function findCorpAuth(corpId: string, deps: WecomStoreDeps): Promise<{ corpId: string, permanentCode: string, agentIds: string[] } | null> {
222
- const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
269
+ export async function findCorpAuth(corpId: string, deps: WecomStoreDeps, kind?: WecomKind): Promise<{ corpId: string, permanentCode: string, agentIds: string[], credentialKind: WecomKind } | null> {
270
+ const rows = await db.table("wecom_app_profile").select(["corp_id", "agent_id", "permanent_code_enc", "credential_kind"]).where(corpKindScope(corpId, kind)).all() as Array<Record<string, unknown>>;
223
271
  if (rows.length === 0)
224
272
  return null;
225
273
  return {
226
274
  corpId: String(rows[0]!.corp_id),
227
275
  permanentCode: deps.decrypt(String(rows[0]!.permanent_code_enc)),
228
276
  agentIds: rows.map(r => String(r.agent_id)),
277
+ credentialKind: kindOfRow(rows[0]!),
229
278
  };
230
279
  }
231
280
 
@@ -286,7 +335,7 @@ export async function claimLoginTicket(ticketHash: string, deps: WecomStoreDeps)
286
335
  }
287
336
 
288
337
  /** 运行时依赖:密钥取自部署常量,加密原语取自 oj 运行时全局 */
289
- export function runtimeStoreDeps(cfg: WecomConfig): WecomStoreDeps {
338
+ export function runtimeStoreDeps(cfg: WecomBase): WecomStoreDeps {
290
339
  return {
291
340
  encrypt: (plain: string) => crypto.aesGcmEncrypt(plain, cfg.encKey),
292
341
  decrypt: (blob: string) => crypto.aesGcmDecrypt(blob, cfg.encKey),
@@ -15,6 +15,7 @@
15
15
  // 致命类计入熔断(连续 FATAL_LIMIT 次 → job failed);
16
16
  // · 错误落库只存脱敏结构(`{errcode, phase, deptId, at}`):异常原文里可能夹带 URL 上的 token。
17
17
  import type { WecomClient, WecomMember } from "./client";
18
+ import type { WecomKind } from "./config";
18
19
  import type { ProfileProbe } from "./store";
19
20
 
20
21
  import { markSeen, reconcileHidden, upsertDepartment } from "#/_platform/_shared/service/domain/org";
@@ -75,10 +76,17 @@ export interface SyncJobView {
75
76
  export interface SyncView extends SyncJobView {
76
77
  /** 本站是否装过企微(有授权记录,含已停用)——false 时页面整条不显示 */
77
78
  connected: boolean
78
- /** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝) */
79
+ /** 0=管理员授权 / 1=成员授权(1 时任何通讯录调用都是 60011,同步入口直接拒绝);自建行恒 0 */
79
80
  authMode: number
80
81
  /** 授权记录的应用状态;disabled = 已取消授权/被停用,同步冻结但数据保留(§7 黄条) */
81
82
  profileStatus: "active" | "disabled" | null
83
+ /**
84
+ * 本次解析到的应用 id:**多应用/共存企业的分辨锚点**。
85
+ * 前端 start / cancel / status 必须原样回传,否则同一家企业两行档案时后端只能 400「必须带 agent」。
86
+ */
87
+ agentId: string | null
88
+ /** 凭据来源:corp 行没有「授权模式」概念,页面不该给它画成员授权卡片 */
89
+ credentialKind: WecomKind | null
82
90
  }
83
91
 
84
92
  export interface SyncDeps {
@@ -191,6 +199,8 @@ export function attachProfile(view: SyncJobView, probe: ProfileProbe | null): Sy
191
199
  connected: probe !== null,
192
200
  authMode: probe?.authMode ?? 0,
193
201
  profileStatus: probe?.status ?? null,
202
+ agentId: probe?.agentId ?? null,
203
+ credentialKind: probe?.credentialKind ?? null,
194
204
  };
195
205
  }
196
206
 
@@ -0,0 +1,194 @@
1
+ // 租户管理员:给每个企业租户(tenant_id = corp_id)配一个**本租户内**的管理员账号。
2
+ //
3
+ // 为什么必须有这一层:
4
+ // 企业租户是企微登录时才由 `ensureTenant()` 隐式具象化的,里面只有 JIT 建出来的
5
+ // `wecom:<userid>` 员工号,**一个管理员都没有**——于是没人能进 /system/departments
6
+ // 做组织架构同步、也没人能在该租户内授权。平台 superadmin 虽然在 /system/wecom
7
+ // 上看得到所有企业,但管不了租户内的组织/成员/角色(详见下面「越权」一段)。
8
+ //
9
+ // 为什么是 `admin` 而不是 `superadmin`:
10
+ // `superadmin` 在本系统是**跨租户**的框架级角色——/system/wecom 每个端点都过
11
+ // `assertSuperAdmin()`,而它列出的 listApps() 是**所有企业**的档案。只要某租户里
12
+ // 有人握着 superadmin,他带自己租户的头访问该页就能看到别家企业的档案,属跨租户
13
+ // 越权。所以:superadmin = 平台级、只留在 default 租户(运维纪律);
14
+ // admin = 租户级、每租户一个(本文件负责开的就是这个)。
15
+ //
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // 【db.asSystem 例外说明 —— 全仓唯一,不得复制】
18
+ //
19
+ // 本文件是全代码库**唯一**使用 `db.asSystem()` 的地方。
20
+ //
21
+ // 为什么必须用它:superadmin 的请求自带 `default` 租户头,而 `db.asTenant` 是
22
+ // 「请求级、只能设一次,且要求当时还没有租户」。也就是说平台侧**没有任何正常通道**
23
+ // 能往企业租户里写 users / sys_user_role。而「给新租户配管理员」是平台开通动作,
24
+ // 不是业务写——正是逃生通道的用途(global.d.ts 原文:"use db.asSystem() for system tasks")。
25
+ //
26
+ // 框架红线:同一段注释紧接着写「业务 handler 不得使用」。所以这里是**有意识且被审计
27
+ // 的单一例外**,代价与边界必须写清楚:
28
+ // · 范围:只在本文件,只服务「开通 / 重设租户管理员」这一件事;
29
+ // · 写操作必须显式带上 tenant_id 条件(asSystem 绕过自动注入,漏了就是跨租户脏写);
30
+ // · 每个入口都在 admin/api.ts 走 recordOp 留痕(开通 level 2、改密 level 4);
31
+ // · **不得在本文件之外任何地方复制 db.asSystem()**。
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ /** 租户管理员的固定账号名(每租户各一个,互不冲突——users 唯一键含 tenant_id) */
35
+ export const TENANT_ADMIN_USERNAME = "admin";
36
+ /** 授予的角色:租户级日常管理(组织/成员/角色/同步),**不是**跨租户的 superadmin */
37
+ const TENANT_ADMIN_ROLE = "admin";
38
+
39
+ export interface TenantAdminState {
40
+ /** 租户(= 企业 ID) */
41
+ corpId: string
42
+ /** 管理员账号名 */
43
+ username: string
44
+ /** 该租户是否已有一个握着 admin 角色的账号 */
45
+ exists: boolean
46
+ /** 是否处于「须改密」状态(开通后恒为 true,直到本人改过一次) */
47
+ mustChange: boolean
48
+ }
49
+
50
+ /**
51
+ * 随机初始口令:**任何人都不知道**(bcrypt 单向,也不落库、不打日志)。
52
+ *
53
+ * 这不是缺陷而是设计:开通时不需要有人知道口令;平台侧要接管时走
54
+ * `setTenantAdminPassword()` **自己设一个已知的**——他输入的那个他本来就知道,
55
+ * 于是「口令需要被传递」这个问题被消掉了,全系统没有任何口令以可逆形式静态存储。
56
+ */
57
+ function randomPassword(): string {
58
+ // randomHex 产出 [0-9a-f],长度 20 > 口令最小长度 6,且数字字母混合
59
+ return crypto.randomHex(10);
60
+ }
61
+
62
+ /** 跨租户读:指定租户下的管理员行(asSystem 例外,见文件头) */
63
+ async function adminRow(tenantId: string): Promise<{ id: number, must_change: number } | null> {
64
+ const rows = await db.asSystem().table("users")
65
+ .select(["id", "must_change"])
66
+ .where({
67
+ and: [
68
+ { field: "tenant_id", op: "eq", value: tenantId },
69
+ { field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
70
+ ],
71
+ })
72
+ .all() as Array<{ id: number, must_change: number | null }>;
73
+ if (!rows.length)
74
+ return null;
75
+ return { id: Number(rows[0]!.id), must_change: Number(rows[0]!.must_change ?? 0) };
76
+ }
77
+
78
+ /** 跨租户读:该用户在本租户是否握着 admin 角色(账号存在 ≠ 有角色,可能被收回过) */
79
+ async function hasAdminRole(tenantId: string, uid: number): Promise<boolean> {
80
+ const roles = await db.asSystem().table("sys_user_role")
81
+ .select(["role_name"])
82
+ .where({
83
+ and: [
84
+ { field: "tenant_id", op: "eq", value: tenantId },
85
+ { field: "user_id", op: "eq", value: uid },
86
+ ],
87
+ })
88
+ .all() as Array<{ role_name: string }>;
89
+ return roles.some(r => String(r.role_name) === TENANT_ADMIN_ROLE);
90
+ }
91
+
92
+ /** 查看某租户的管理员状态(幂等、只读;不存在不建) */
93
+ export async function tenantAdminState(tenantId: string): Promise<TenantAdminState> {
94
+ const row = await adminRow(tenantId);
95
+ if (!row)
96
+ return { corpId: tenantId, username: TENANT_ADMIN_USERNAME, exists: false, mustChange: false };
97
+ return {
98
+ corpId: tenantId,
99
+ username: TENANT_ADMIN_USERNAME,
100
+ exists: await hasAdminRole(tenantId, row.id),
101
+ mustChange: row.must_change === 1,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 开通租户管理员(幂等):没有就建,有就只确认角色还在。
107
+ *
108
+ * 只做两件事:建 `admin` 账号(随机口令 + 强制改密)、授予本租户的 `admin` 角色。
109
+ * 重复调用安全——这正是「验证并接入可以反复点」的前提。
110
+ */
111
+ export async function ensureTenantAdmin(tenantId: string): Promise<{ created: boolean }> {
112
+ let row = await adminRow(tenantId);
113
+
114
+ if (!row) {
115
+ // 与 ensureWecomUser 同形状:只填业务关心的列,其余走列默认值
116
+ await db.asSystem().table("users").insert({
117
+ tenant_id: tenantId,
118
+ username: TENANT_ADMIN_USERNAME,
119
+ display_name: "租户管理员",
120
+ password_hash: await bcrypt.hash(randomPassword()),
121
+ avatar_base64: "",
122
+ source: "local",
123
+ must_change: 1,
124
+ is_forbidden: 0,
125
+ activated: 1,
126
+ }).run();
127
+ // 回查拿 id:预查与插入之间有竞态(并发开通),回查是唯一可靠的取 id 方式
128
+ row = await adminRow(tenantId);
129
+ }
130
+ if (!row)
131
+ throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
132
+
133
+ if (!await hasAdminRole(tenantId, row.id)) {
134
+ await db.asSystem().table("sys_user_role").insert({
135
+ user_id: row.id,
136
+ role_name: TENANT_ADMIN_ROLE,
137
+ tenant_id: tenantId,
138
+ }).run();
139
+ return { created: true };
140
+ }
141
+ return { created: false };
142
+ }
143
+
144
+ /**
145
+ * 开通租户管理员(**尽力而为**):开不通只告警,绝不打断调用方。
146
+ *
147
+ * 为什么调用方不能因为它失败:
148
+ * · create_auth 有约 1 秒响应窗口(超时企业管理员看到「应用安装失败」),而 bcrypt
149
+ * 是 CPU 密集操作,不该让它有机会拖垮回调;
150
+ * · connectCorp 走到这里时档案**已经落库**,抛错只会让界面显示「接入失败」而实际已接入。
151
+ *
152
+ * 补开通道:superadmin 随时可走「重设租户管理员口令」入口,该入口内部会先
153
+ * `ensureTenantAdmin()`,所以一次开通失败不会留下没有管理员的死租户。
154
+ */
155
+ export async function provisionTenantAdmin(tenantId: string): Promise<void> {
156
+ try {
157
+ const r = await ensureTenantAdmin(tenantId);
158
+ log.info("wecom tenant admin provisioned", "corpId", tenantId, "created", r.created);
159
+ }
160
+ catch (e) {
161
+ log.warn("wecom tenant admin provision skipped", "corpId", tenantId, "reason", (e as Error).message);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 重设租户管理员口令(平台 superadmin 专用入口)。
167
+ *
168
+ * 口令**不存储**:superadmin 自己输入新口令 → 他知道 → 告知租户管理员。
169
+ * 落库的只有 bcrypt,且置 must_change=1(首次登录必须改),并级联吊销该用户的
170
+ * refresh token,避免旧会话继续有效。
171
+ */
172
+ export async function setTenantAdminPassword(tenantId: string, password: string): Promise<TenantAdminState> {
173
+ // 先确保账号与角色都在:允许「还没人登录过」的租户被平台直接接管
174
+ await ensureTenantAdmin(tenantId);
175
+ const row = await adminRow(tenantId);
176
+ if (!row)
177
+ throw Object.assign(new Error("租户管理员建档失败"), { status: 500 });
178
+
179
+ await db.asSystem().table("users")
180
+ .update({ password_hash: await bcrypt.hash(password), must_change: 1 })
181
+ .where({
182
+ and: [
183
+ { field: "tenant_id", op: "eq", value: tenantId },
184
+ { field: "username", op: "eq", value: TENANT_ADMIN_USERNAME },
185
+ ],
186
+ })
187
+ .run();
188
+
189
+ // 改密后旧会话必须失效。跨租户删 refresh 走带 tenant_id 条件的裸 SQL
190
+ // (先例见 store.ts::revokeTenantSessions:守卫是 check-on-miss,带了租户条件即放行)
191
+ await db.exec("DELETE FROM sys_refresh_token WHERE tenant_id = ? AND user_id = ?", [tenantId, row.id]);
192
+
193
+ return await tenantAdminState(tenantId);
194
+ }
@@ -0,0 +1,3 @@
1
+ -- 应用来源(0003)mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:MySQL 的 TEXT 不能带 DEFAULT,故收窄为 VARCHAR(16)。
3
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind VARCHAR(16) NOT NULL DEFAULT 'suite';
@@ -0,0 +1,3 @@
1
+ -- 应用来源(0003)pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
2
+ -- 差异:TEXT 在 pg 合法且可带 DEFAULT,与通用文件同形。
3
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind TEXT NOT NULL DEFAULT 'suite';
@@ -0,0 +1,9 @@
1
+ -- 应用来源(0003):suite = 代开发(服务商模板授权) / corp = 企业内部应用(自建应用)。
2
+ --
3
+ -- 为什么必须有这一列,而不是靠 `suite_id = ''` 推断:
4
+ -- · 查询要按来源筛(取消代开发授权不能顺手停掉同一家企业自建应用的档案)、后台要按来源展示;
5
+ -- · 空串是「主键里塞了一个隐式语义」,任何一次手写 SQL 漏判都会把两类行混在一起;
6
+ -- · 将来若做「同一企业双应用」,区分只能靠列,不能靠约定。
7
+ --
8
+ -- 现有行全部是代开发授权产生的,故 DEFAULT 'suite' 即正确的历史归属。
9
+ ALTER TABLE wecom_app_profile ADD COLUMN credential_kind TEXT NOT NULL DEFAULT 'suite';
@@ -23,6 +23,8 @@ tables:
23
23
  status: {type: text, null: false}
24
24
  # permanent_code(= 代开发应用的 secret)的 AES-GCM 密文,禁止明文落库
25
25
  permanent_code_enc: {type: text, null: false}
26
+ # 应用来源(0003):suite=代开发 / corp=企业内部应用(自建)。取消代开发授权只停 suite 行
27
+ credential_kind: {type: text, null: false}
26
28
  # 0=管理员授权 / 1=成员授权(0002):成员授权下应用无任何部门权限,组织架构同步必然 60011
27
29
  auth_mode: {type: integer, null: false}
28
30
  installed_at: {type: integer, null: false}