@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
@@ -1,27 +1,51 @@
1
- import { fetchLoginMethods, getRedirectPath, useAuthStore } from "@oj-module/runtime";
1
+ import {
2
+ DEFAULT_TENANT,
3
+ fetchLoginMethods,
4
+ getRedirectPath,
5
+ resolveApiBase,
6
+ useAuthStore,
7
+ useTenantStore,
8
+ } from "@oj-module/runtime";
2
9
  import { Alert, Button, Form, Input, Tabs, Typography } from "antd";
3
- import { useEffect, useState } from "react";
10
+ import { useEffect, useRef, useState } from "react";
4
11
  import { useTranslation } from "react-i18next";
5
12
  import { useLocation, useNavigate } from "react-router";
13
+ import { parseWecomLanding, wecomAuthorizePath } from "../wecom";
6
14
 
7
- type LoginMethod = "local" | "ldap" | "sso";
15
+ type LoginMethod = "local" | "ldap" | "sso" | "wecom";
16
+
17
+ /**
18
+ * 已实现的登录方式(顺序即 tab 顺序)。
19
+ * 后端 `methods` 是**发现结果**、这里是**渲染能力**:后端多报一种而本页没有对应
20
+ * renderer 时宁可不显示(点了也没实现),新增方式 = 这里加一项 + 渲染分支加一路。
21
+ */
22
+ const KNOWN_METHODS: LoginMethod[] = ["local", "ldap", "sso", "wecom"];
23
+
24
+ function isKnownMethod(x: string): x is LoginMethod {
25
+ return (KNOWN_METHODS as string[]).includes(x);
26
+ }
8
27
 
9
28
  interface LoginFormValues {
10
29
  username: string
11
30
  password: string
31
+ /** 企业代码(多租户部署才需要,L2:登录带租户) */
32
+ tenant?: string
12
33
  }
13
34
 
14
35
  /**
15
36
  * 登录页内容区。
16
37
  *
17
38
  * 只写内容区:视口/品牌区/工具区/页脚由框架 FullscreenLayout 兜住。
18
- * 只依赖框架出口(useAuthStore / getRedirectPath / fetchLoginMethods)与共享依赖
39
+ * 只依赖框架出口(useAuthStore / getRedirectPath / fetchLoginMethods / withSiteBase)与共享依赖
19
40
  * (antd / react-router),零框架内部 import。
20
41
  *
21
42
  * 登录方式由后端匿名端点 `GET /system/login-methods` 发现(local 恒在,
22
- * ldap/sso 由系统设置开关)。多于一种方式时渲染切换 tab;local/ldap 走同一表单,
23
- * 仅把 `method` 透传给运行时 `login()`(其按 method 路由到 auth/login / auth/ldap-login);
24
- * sso 走重定向(端点由宿主/平台提供,模板仅负责跳转)。
43
+ * ldap/sso/wecom 由系统设置开关)。多于一种方式时渲染切换 tab;local/ldap 走同一表单,
44
+ * 仅把 `method` 透传给运行时 `login()`;sso 与 wecom 走整页跳转(拿不到 302 的
45
+ * opaque redirect,不能用 XHR)。
46
+ *
47
+ * 企微回跳落地:`/login?wecom_ticket=&tenant=` —— 先落租户再换票(服务端要求头与票据
48
+ * 租户一致),票换出的 token 由 runtime 统一写库(见 ../auth-provider.ts)。
25
49
  */
26
50
  export default function LoginPage() {
27
51
  const { t } = useTranslation();
@@ -32,11 +56,27 @@ export default function LoginPage() {
32
56
  const [loading, setLoading] = useState(false);
33
57
  const [methods, setMethods] = useState<LoginMethod[]>(["local"]);
34
58
  const [active, setActive] = useState<LoginMethod>("local");
59
+ // 企业代码入口默认收起:单租户部署(绝大多数)保持今天的登录页外观,
60
+ // 只有服务商/客户带 `?tenant=` 或手工展开时才需要填。
61
+ // 例外:持久化租户不是部署默认值时必须展开——租户头是**隐式**跟着请求走的
62
+ // (runtime 每请求注入),藏起来等于让上一次的租户无声地把这次登录带走。
63
+ const [showTenant, setShowTenant] = useState(() => useTenantStore.getState().tenant !== DEFAULT_TENANT);
64
+ // 表单里的企业代码:带上 persisted 值(URL `?tenant=` 已先落到 store),用户看得见才改得动
65
+ const [tenant] = useState(() => {
66
+ const current = useTenantStore.getState().tenant;
67
+ return current === DEFAULT_TENANT ? "" : current;
68
+ });
69
+ // 企微 tab 的企业代码(优先用已持久化的值,单租户的 default 不预填)
70
+ const [corp, setCorp] = useState(() => tenant);
71
+
72
+ // 企微 302 落地参数:只在首次渲染解析一次(ticket 一次性,解析结果必须稳定)
73
+ const [landing] = useState(() => parseWecomLanding(search));
74
+ const exchangedRef = useRef(false);
35
75
 
36
76
  useEffect(() => {
37
77
  fetchLoginMethods()
38
78
  .then((r) => {
39
- const m = r.methods.filter((x): x is LoginMethod => x === "local" || x === "ldap" || x === "sso");
79
+ const m = r.methods.filter(isKnownMethod);
40
80
  if (!m.length)
41
81
  return;
42
82
  setMethods(m);
@@ -48,11 +88,40 @@ export default function LoginPage() {
48
88
  });
49
89
  }, []);
50
90
 
91
+ useEffect(() => {
92
+ // 换票只做一次:ticket 单次有效,重复 POST 只会拿到 401 并盖掉真正的错误
93
+ if (!landing || exchangedRef.current)
94
+ return;
95
+ exchangedRef.current = true;
96
+ void (async () => {
97
+ setLoading(true);
98
+ setError(undefined);
99
+ try {
100
+ // 租户先落 store:provider 每请求读 getState,服务端会比对「头值 == 票据租户」
101
+ if (landing.tenant)
102
+ useTenantStore.getState().setTenant(landing.tenant);
103
+ await login({ username: "", password: "", method: "wecom", ticket: landing.ticket });
104
+ navigate(getRedirectPath(search), { replace: true });
105
+ }
106
+ catch (e) {
107
+ setError(e instanceof Error ? e.message : String(e));
108
+ setLoading(false);
109
+ }
110
+ })();
111
+ }, [landing, login, navigate, search]);
112
+
51
113
  async function onFinish(values: LoginFormValues, method: LoginMethod) {
52
114
  setLoading(true);
53
115
  setError(undefined);
54
116
  try {
55
- await login({ ...values, method });
117
+ // L2:登录请求必须带租户头。先落 store(provider 每请求读 getState),再登录。
118
+ // 字段留空即「回到部署默认」——否则上一个用户/上一次企微登录留下的租户头
119
+ // 会把这次本地登录静默带到别家企业(表现为账号不存在,界面上看不出原因)。
120
+ if (values.tenant?.trim())
121
+ useTenantStore.getState().setTenant(values.tenant);
122
+ else
123
+ useTenantStore.getState().clear();
124
+ await login({ username: values.username, password: values.password, method });
56
125
  navigate(getRedirectPath(search));
57
126
  }
58
127
  catch (e) {
@@ -65,7 +134,17 @@ export default function LoginPage() {
65
134
 
66
135
  function ssoLogin() {
67
136
  // SSO 重定向端点由宿主/平台提供(如 GET /auth/sso),模板侧仅跳转。
68
- window.location.assign("/auth/sso");
137
+ // 与企微同口径拼 API base:它是**后端端点**(要发 302 到 IdP),拼站点 base
138
+ // 只会落到 SPA 兜底页(点了没反应,一个请求都不发)。
139
+ window.location.assign(`${resolveApiBase()}/auth/sso`);
140
+ }
141
+
142
+ function wecomLogin() {
143
+ // 整页跳转(拿不到 302 的 opaque redirect):授权入口是**后端端点**,
144
+ // 必须拼 API base(部署期可为 /api 或网关前缀);拼站点 base 只会落到
145
+ // SPA 兜底页——点击后页面原地重载、一个 auth 请求都不发。
146
+ // 企业代码不在这里写 store——只有服务端回跳带的 tenant 才是权威值。
147
+ window.location.assign(`${resolveApiBase()}${wecomAuthorizePath(corp)}`);
69
148
  }
70
149
 
71
150
  const showTabs = methods.length > 1;
@@ -78,12 +157,58 @@ export default function LoginPage() {
78
157
  <Form.Item name="password" label={t("login:page.password")} rules={[{ required: true }]}>
79
158
  <Input.Password />
80
159
  </Form.Item>
160
+ {showTenant && (
161
+ <Form.Item
162
+ name="tenant"
163
+ label={t("login:page.tenant.label")}
164
+ extra={t("login:page.tenant.hint")}
165
+ initialValue={tenant}
166
+ >
167
+ <Input autoComplete="off" placeholder="ww..." />
168
+ </Form.Item>
169
+ )}
81
170
  <Button type="primary" htmlType="submit" block loading={loading}>
82
171
  {t("login:page.submit")}
83
172
  </Button>
173
+ <Button type="link" size="small" block onClick={() => setShowTenant(v => !v)}>
174
+ {t(showTenant ? "login:page.tenant.hide" : "login:page.tenant.toggle")}
175
+ </Button>
84
176
  </Form>
85
177
  );
86
178
 
179
+ // 企微:只需企业代码(服务商后台生成的专属链接会预填,客户也可手输)
180
+ // 受控值不走 antd 表单(Form.Item 无 name),故显式给 id/htmlFor:
181
+ // 少了这层关联,标签只是视觉文本,读屏与 getByLabel 都取不到输入框。
182
+ const wecomCorpId = "wecom-corp";
183
+ const wecomPanel = (
184
+ <Form layout="vertical" onFinish={wecomLogin}>
185
+ <Form.Item
186
+ label={t("login:page.tenant.label")}
187
+ htmlFor={wecomCorpId}
188
+ extra={t("login:method.wecom.hint")}
189
+ >
190
+ <Input
191
+ id={wecomCorpId}
192
+ autoComplete="off"
193
+ placeholder="ww..."
194
+ value={corp}
195
+ onChange={e => setCorp(e.target.value)}
196
+ />
197
+ </Form.Item>
198
+ <Button type="primary" htmlType="submit" block>
199
+ {t("login:method.wecom.action")}
200
+ </Button>
201
+ </Form>
202
+ );
203
+
204
+ function panelOf(m: LoginMethod) {
205
+ if (m === "sso")
206
+ return <Button type="primary" block onClick={ssoLogin}>{t("login:method.sso.action")}</Button>;
207
+ if (m === "wecom")
208
+ return wecomPanel;
209
+ return form(m);
210
+ }
211
+
87
212
  return (
88
213
  <>
89
214
  <Typography.Title level={3}>
@@ -92,22 +217,26 @@ export default function LoginPage() {
92
217
  <Typography.Paragraph type="secondary">
93
218
  {t("login:page.subtitle")}
94
219
  </Typography.Paragraph>
220
+ {landing && loading && (
221
+ <Alert style={{ marginBottom: 16 }} type="info" message={t("login:method.wecom.exchanging")} showIcon />
222
+ )}
95
223
  {error && <Alert style={{ marginBottom: 16 }} type="error" message={error} showIcon />}
96
- {showTabs
97
- ? (
98
- <Tabs
99
- activeKey={active}
100
- onChange={key => setActive(key as LoginMethod)}
101
- items={methods.map(m => ({
102
- key: m,
103
- label: t(`login:method.${m}`),
104
- children: m === "sso"
105
- ? <Button type="primary" block onClick={ssoLogin}>{t("login:method.sso.action")}</Button>
106
- : form(m),
107
- }))}
108
- />
109
- )
110
- : form("local")}
224
+ {/* 换票期间不渲染 tab:避免用户在跳转前又点一次登录 */}
225
+ {landing && loading
226
+ ? null
227
+ : showTabs
228
+ ? (
229
+ <Tabs
230
+ activeKey={active}
231
+ onChange={key => setActive(key as LoginMethod)}
232
+ items={methods.map(m => ({
233
+ key: m,
234
+ label: t(`login:method.${m}`),
235
+ children: panelOf(m),
236
+ }))}
237
+ />
238
+ )
239
+ : panelOf(methods[0] ?? "local")}
111
240
  </>
112
241
  );
113
242
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 企微登录的**纯逻辑**(无 react / 无 request / 无 runtime 依赖),便于 node 侧回归:
3
+ * 授权入口 URL 拼装、回跳落地参数解析。
4
+ *
5
+ * 与后端的分工(§5.4/§11.5):
6
+ * · 发起:浏览器整页跳 `/auth/wecom/login?corp=` → 后端 302 到企微(前端拿不到 opaque redirect,
7
+ * 所以必须 window.location.assign,不能用 XHR/fetch);
8
+ * · 回跳:后端 302 回 `/login?wecom_ticket=&tenant=`,本文件把这两个参数解析出来;
9
+ * · 换票:ticket → POST /auth/wecom/ticket(由 auth-provider 完成,store 写入仍归 runtime)。
10
+ */
11
+
12
+ /** 回跳落地参数名(后端 loginLandingUrl 拼的,改这里必须同时改后端) */
13
+ export const WECOM_TICKET_PARAM = "wecom_ticket";
14
+ export const WECOM_TENANT_PARAM = "tenant";
15
+
16
+ export interface WecomLanding {
17
+ /** 一次性登录票据(180 秒、只能用一次) */
18
+ ticket: string
19
+ /** 票据所属企业代码;空串表示后端没带(此时沿用本地已存的租户) */
20
+ tenant: string
21
+ }
22
+
23
+ /**
24
+ * 解析回跳落地参数。非企微落地(普通打开登录页)返回 null——
25
+ * 调用方据此决定「走表单」还是「直接换票」。
26
+ */
27
+ export function parseWecomLanding(search: string): WecomLanding | null {
28
+ const params = new URLSearchParams(search);
29
+ const ticket = params.get(WECOM_TICKET_PARAM)?.trim() ?? "";
30
+ if (!ticket)
31
+ return null;
32
+ return { ticket, tenant: params.get(WECOM_TENANT_PARAM)?.trim() ?? "" };
33
+ }
34
+
35
+ /**
36
+ * 授权入口(**后端端点**路径,调用方负责拼 `resolveApiBase()` 后整页跳转)。
37
+ * 拼站点 base 是错的:请求会落到 SPA 兜底页,点了不动且无从排查——
38
+ * `/auth/wecom/login` 的真实前缀由部署的 API base 决定(可为 /api 或网关前缀)。
39
+ * `corp` 为空时**照样跳**:后端会回 400 并说明缺企业代码,比前端静默不动更容易排查。
40
+ */
41
+ export function wecomAuthorizePath(corp: string, agent?: string): string {
42
+ const params = new URLSearchParams();
43
+ const c = corp.trim();
44
+ if (c)
45
+ params.set("corp", c);
46
+ const a = agent?.trim();
47
+ if (a)
48
+ params.set("agent", a);
49
+ const qs = params.toString();
50
+ return `/auth/wecom/login${qs ? `?${qs}` : ""}`;
51
+ }
@@ -220,9 +220,12 @@ export async function markAllRead(userId: string | number): Promise<void> {
220
220
  // oj 0.1.81 的 orIgnore 仅跟随 VALUES 的 upsert 子句,不与 fromSelect(SELECT 源) 组合
221
221
  // (实测:code 500 "orIgnore with fromSelect is not supported on sqlite")。原 SQL 三方言通用
222
222
  // (sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql ON DUPLICATE KEY),退回裸 SQL。
223
+ // 多租户(0015):裸 SQL 必须显式写 tenant_id 且把当前租户作为绑定参数传进去,
224
+ // 否则 sql_guard=deny 直接拒(缺列 / 参数不含租户值两种都会被拦)。
225
+ const tenant = http.tenantId;
223
226
  await db.exec(
224
- "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
225
- [uid, now],
227
+ "INSERT OR IGNORE INTO notifications_broadcast_read (tenant_id, broadcast_id, user_id, read_time) SELECT tenant_id, id, ?, ? FROM notifications_broadcast WHERE tenant_id = ?",
228
+ [uid, now, tenant],
226
229
  );
227
230
  }
228
231
 
@@ -231,9 +234,10 @@ export async function clearNotices(userId: string | number): Promise<void> {
231
234
  const uid = Number(userId);
232
235
  await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).delete().run();
233
236
  // 保留 db.exec:INSERT OR IGNORE ... SELECT 同 markAllRead(构造器 orIgnore 不与
234
- // fromSelect 组合,实测 500),原 SQL 三方言通用。
237
+ // fromSelect 组合,实测 500),原 SQL 三方言通用。多租户同上:tenant_id 显式 + 绑定参数。
238
+ const tenant = http.tenantId;
235
239
  await db.exec(
236
- "INSERT OR IGNORE INTO notifications_broadcast_hidden (broadcast_id, user_id, create_time) SELECT id, ?, ? FROM notifications_broadcast",
237
- [uid, nowStr()],
240
+ "INSERT OR IGNORE INTO notifications_broadcast_hidden (tenant_id, broadcast_id, user_id, create_time) SELECT tenant_id, id, ?, ? FROM notifications_broadcast WHERE tenant_id = ?",
241
+ [uid, nowStr(), tenant],
238
242
  );
239
243
  }
@@ -7,6 +7,7 @@
7
7
  // 出参挂平级 `.response`(动词级优先于组级,组级沿嵌套继承)——oj 运行期据此校验入参、
8
8
  // 并供 `oj openapi` 生成 openapi.json → 前端 `ojm api` 生成 __client。
9
9
  // `.schema` 是 JSON Schema 严格子集(关键字白名单),union 用类型数组表达(见 userId)。
10
+ import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
10
11
  import { clearNotices, markAllRead, markRead, noticesFor, pushBroadcastNotice, pushNotice } from "./_shared/notify";
11
12
 
12
13
  const notifications = {
@@ -19,6 +20,10 @@ const notifications = {
19
20
  json.fail(401, "unauthorized");
20
21
  return;
21
22
  }
23
+ if (!(await isSelfTenant())) {
24
+ json.fail(403, "forbidden");
25
+ return;
26
+ }
22
27
  try {
23
28
  json.ok(await noticesFor(uid));
24
29
  }
@@ -35,6 +40,10 @@ const notifications = {
35
40
  json.fail(401, "unauthorized");
36
41
  return;
37
42
  }
43
+ if (!(await isSelfTenant())) {
44
+ json.fail(403, "forbidden");
45
+ return;
46
+ }
38
47
  const body = (http.body ?? {}) as { id?: unknown };
39
48
  const id = String(body.id ?? "");
40
49
  if (!id) {
@@ -62,6 +71,10 @@ const notifications = {
62
71
  json.fail(401, "unauthorized");
63
72
  return;
64
73
  }
74
+ if (!(await isSelfTenant())) {
75
+ json.fail(403, "forbidden");
76
+ return;
77
+ }
65
78
  try {
66
79
  await markAllRead(uid);
67
80
  json.ok(null);
@@ -80,6 +93,10 @@ const notifications = {
80
93
  json.fail(401, "unauthorized");
81
94
  return;
82
95
  }
96
+ if (!(await isSelfTenant())) {
97
+ json.fail(403, "forbidden");
98
+ return;
99
+ }
83
100
  try {
84
101
  await clearNotices(uid);
85
102
  json.ok(null);
@@ -133,6 +150,10 @@ const push = {
133
150
  json.fail(401, "unauthorized");
134
151
  return;
135
152
  }
153
+ if (!(await isSelfTenant())) {
154
+ json.fail(403, "forbidden");
155
+ return;
156
+ }
136
157
  const b = (http.body ?? {}) as {
137
158
  userId?: unknown
138
159
  title?: unknown
@@ -187,6 +208,10 @@ const broadcast = {
187
208
  json.fail(401, "unauthorized");
188
209
  return;
189
210
  }
211
+ if (!(await isSelfTenant())) {
212
+ json.fail(403, "forbidden");
213
+ return;
214
+ }
190
215
  const b = (http.body ?? {}) as {
191
216
  title?: unknown
192
217
  message?: unknown
@@ -1,5 +1,6 @@
1
1
  // POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
2
2
  // 按人项校验归属,非本人返回 403;广播项仅删本人回执。Bearer 守卫保护。
3
+ import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
3
4
  import { delNotice } from "../_shared/notify";
4
5
 
5
6
  // POST /api/notification/del —— 删除一条通知(发射原语 HTTP 入口)。
@@ -12,6 +13,10 @@ const handler = {
12
13
  json.fail(401, "unauthorized");
13
14
  return;
14
15
  }
16
+ if (!(await isSelfTenant())) {
17
+ json.fail(403, "forbidden");
18
+ return;
19
+ }
15
20
  const b = (http.body ?? {}) as { id?: unknown, userId?: unknown };
16
21
  const id = String(b.id ?? "");
17
22
  if (!id) {
@@ -1,6 +1,9 @@
1
1
  name: notification
2
2
  desc: 通知模块(契约化通知读写:读 + 单条已读 + 全部已读 + 清空 + 广播;表与种子归属本模块)
3
3
  version: 0.1.0
4
+ deps: # 租户自洽守卫(isSelfTenant)读 _platform 的 users 判定「token 的 sub 是否在本租户」;
5
+ # 跨模块别名 `#/_platform/…` 的依赖门禁(oj build S008)
6
+ _platform: ^0.1.0
4
7
  tables:
5
8
  - notifications
6
9
  - notifications_broadcast
@@ -0,0 +1,6 @@
1
+ -- 0005__tenant(mysql 方言):通知四表租户化。
2
+ -- 差异:TEXT 不能带字面默认值 → 租户列用 VARCHAR(255)。
3
+ ALTER TABLE notifications ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
4
+ ALTER TABLE notifications_broadcast ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
5
+ ALTER TABLE notifications_broadcast_read ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
6
+ ALTER TABLE notifications_broadcast_hidden ADD COLUMN tenant_id VARCHAR(255) NOT NULL DEFAULT 'default';
@@ -0,0 +1,6 @@
1
+ -- 0005__tenant:通知四表租户化(企微代开发 v3 Phase 0 的 A 组)。
2
+ -- 广播/按人通知都是租户内数据:不同 corp 的公告与已读回执必须互不可见。
3
+ ALTER TABLE notifications ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
4
+ ALTER TABLE notifications_broadcast ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
5
+ ALTER TABLE notifications_broadcast_read ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
6
+ ALTER TABLE notifications_broadcast_hidden ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
@@ -5,6 +5,7 @@ tables:
5
5
  pk: id
6
6
  columns:
7
7
  id: {type: integer, autoincrement: true}
8
+ tenant_id: {type: text, null: false}
8
9
  user_id: {type: integer, null: true}
9
10
  avatar: {type: text, null: false}
10
11
  date: {type: text, null: false}
@@ -17,6 +18,7 @@ tables:
17
18
  pk: id
18
19
  columns:
19
20
  id: {type: integer, autoincrement: true}
21
+ tenant_id: {type: text, null: false}
20
22
  avatar: {type: text, null: false}
21
23
  date: {type: text, null: false}
22
24
  message: {type: text, null: false}
@@ -28,6 +30,7 @@ tables:
28
30
  pk: id
29
31
  columns:
30
32
  id: {type: integer, autoincrement: true}
33
+ tenant_id: {type: text, null: false}
31
34
  broadcast_id: {type: integer, null: false}
32
35
  user_id: {type: integer, null: false}
33
36
  read_time: {type: text, null: false}
@@ -35,6 +38,7 @@ tables:
35
38
  pk: id
36
39
  columns:
37
40
  id: {type: integer, autoincrement: true}
41
+ tenant_id: {type: text, null: false}
38
42
  broadcast_id: {type: integer, null: false}
39
43
  user_id: {type: integer, null: false}
40
44
  create_time: {type: text, null: false}
@@ -1,6 +1,9 @@
1
+ import { RESERVED_USERNAME_PREFIX } from "#/_platform/_shared/service/domain/users";
1
2
  // POST /api/personal-center/profile —— 保存「我的资料」(个人中心表单入库端点)。
2
3
  // 只允许改本人行(http.user.id 权威,不接受 body 传 id);username 全局唯一,
3
4
  // 重名 400;avatar 走 upload 端点(multipart),不在此通道。
5
+ import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
6
+
4
7
  const handler = {
5
8
  async post() {
6
9
  const uid = http.user?.id;
@@ -8,6 +11,10 @@ const handler = {
8
11
  json.fail(401, "unauthorized");
9
12
  return;
10
13
  }
14
+ if (!(await isSelfTenant())) {
15
+ json.fail(403, "forbidden");
16
+ return;
17
+ }
11
18
  const b = (http.body ?? {}) as {
12
19
  username?: unknown
13
20
  email?: unknown
@@ -17,6 +24,12 @@ const handler = {
17
24
  const patch: Record<string, string> = {};
18
25
  if (b.username != null && String(b.username).trim() !== "") {
19
26
  const username = String(b.username).trim();
27
+ // 与建号同红线(B6):自助改名不能抢占 ldap:/sso:/wecom: 命名空间——
28
+ // 否则该外部成员首次登录时 ensureXXXUser 按用户名查到的是**别人那一行**,直接签发别人的 uid。
29
+ if (RESERVED_USERNAME_PREFIX.test(username)) {
30
+ json.fail(400, "username prefix reserved for external identity sources");
31
+ return;
32
+ }
20
33
  const dup = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
21
34
  if (dup.some(r => Number(r.id) !== Number(uid))) {
22
35
  json.fail(400, "username already taken");
@@ -6,6 +6,8 @@
6
6
  // · 大文件(> max_upload):服务端已流式直落 blob、字节不进 handler,此时
7
7
  // http.file(0) 会**报错**,必须改走 files[0].key + blob.get(key)。
8
8
  // 头像通常远小于阈值,但别写成「只会一种」——换了上传组件/放开体积就会 500。
9
+ import { isSelfTenant } from "#/_platform/_shared/service/infra/access";
10
+
9
11
  function toBase64(bytes: Uint8Array): string {
10
12
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
11
13
  let out = "";
@@ -23,7 +25,6 @@ function toBase64(bytes: Uint8Array): string {
23
25
  return out;
24
26
  }
25
27
 
26
- // POST /api/personal-center/upload —— 头像上传(multipart→base64,P4-3,D7/F4)。
27
28
  const handler = {
28
29
  async post() {
29
30
  const uid = http.user?.id;
@@ -31,6 +32,12 @@ const handler = {
31
32
  json.fail(401, "unauthorized");
32
33
  return;
33
34
  }
35
+ // 写入目标按 uid 定位 users 行:伪造 X-TENANT-ID 时该行在本租户不存在,
36
+ // 不拦就会「更新 0 行却回 200」,还顺带在 blob 里留下垃圾文件
37
+ if (!(await isSelfTenant())) {
38
+ json.fail(403, "forbidden");
39
+ return;
40
+ }
34
41
  if (!http.files || http.files.length === 0) {
35
42
  json.fail(400, "no file");
36
43
  return;
@@ -13,6 +13,7 @@ const Roles = lazy(() => import("./pages/roles"));
13
13
  const Oplog = lazy(() => import("./pages/oplog"));
14
14
  const Settings = lazy(() => import("./pages/settings"));
15
15
  const LoginMethods = lazy(() => import("./pages/login-methods"));
16
+ const WecomAdmin = lazy(() => import("./pages/wecom"));
16
17
 
17
18
  const routes: AppRouteRecordRaw[] = [
18
19
  {
@@ -56,6 +57,13 @@ const routes: AppRouteRecordRaw[] = [
56
57
  Component: LoginMethods,
57
58
  handle: { title: "system:menu.loginMethods" },
58
59
  },
60
+ {
61
+ // 企微代开发后台(服务商运营面):页面内再判一次 superadmin(路由级不做角色门禁,
62
+ // 与 users/roles 页同策略——403 面板比路由消失更容易解释)
63
+ path: "/system/wecom",
64
+ Component: WecomAdmin,
65
+ handle: { title: "system:menu.wecom" },
66
+ },
59
67
  ],
60
68
  },
61
69
  ];
@@ -6,7 +6,8 @@
6
6
  "roles": "Roles & Permissions",
7
7
  "oplog": "Audit Log",
8
8
  "settings": "Site Settings",
9
- "loginMethods": "Login Methods"
9
+ "loginMethods": "Login Methods",
10
+ "wecom": "WeCom Integration"
10
11
  },
11
12
  "common": {
12
13
  "confirm": "Confirm",
@@ -73,7 +74,36 @@
73
74
  "title": "Enabled login methods",
74
75
  "methodLocal": "Local password",
75
76
  "methodLdap": "LDAP",
76
- "methodSso": "SSO"
77
+ "methodSso": "SSO",
78
+ "methodWecom": "WeCom"
79
+ },
80
+ "wecom": {
81
+ "title": "Onboard a corporation",
82
+ "hint": "Click \"Generate install link\" and send the link or QR code to the corporation's admin for approval. The callback on the corporation side writes the row into this table after approval. The link expires in 10 minutes and works once; the QR code greys out when it does, so regenerate it.",
83
+ "appsTitle": "Onboarded corporations",
84
+ "genInstall": "Generate install link",
85
+ "installOk": "Generated. Send the link to the corporation's admin.",
86
+ "installUrl": "Install link",
87
+ "callbackUrl": "Callback URL",
88
+ "expiresIn": "Valid for",
89
+ "expiresValue": "{{count}} minutes",
90
+ "col": {
91
+ "corp": "Corporation",
92
+ "agent": "App",
93
+ "env": "Env",
94
+ "status": "Status",
95
+ "installedAt": "Onboarded at",
96
+ "action": "Actions"
97
+ },
98
+ "statusActive": "Active",
99
+ "statusDisabled": "Disabled",
100
+ "statusOk": "Status updated",
101
+ "statusOkRevoked": "Status updated; {{count}} login sessions of this corporation were revoked",
102
+ "disable": "Disable",
103
+ "enable": "Enable",
104
+ "disableConfirm": "Disable this app? Members of this corporation will no longer be able to sign in with it.",
105
+ "enableConfirm": "Enable this app?",
106
+ "empty": "No corporations onboarded yet"
77
107
  },
78
108
  "err": {
79
109
  "forbidden": "Forbidden",
@@ -91,6 +121,7 @@
91
121
  "site_title": "Site Title",
92
122
  "ldap_enabled": "Enable LDAP login",
93
123
  "sso_enabled": "Enable SSO login",
124
+ "wecom_enabled": "Enable WeCom login",
94
125
  "ldap_base_dn": "LDAP Base DN",
95
126
  "ldap_uid_attr": "LDAP UID Attribute",
96
127
  "ldap_email_attr": "LDAP Email Attribute"