@oj-module/cli 0.2.66 → 0.2.68
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/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +38 -4
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +146 -24
- package/devkit/menu-rules.md +19 -4
- package/devkit/module-dev-and-cdn-playground.md +4 -0
- package/devkit/publish-walkthrough.md +132 -37
- package/devkit/wecom-internal-app.md +8 -3
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +31 -0
- package/src/__tests__/new-scaffold.test.ts +13 -0
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/build/module-def.ts +1 -0
- package/src/builtin.ts +3 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +21 -5
- package/src/dev.ts +15 -10
- package/src/init.ts +47 -41
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +8 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/new.ts +2 -0
- package/src/scaffold-guard.ts +227 -0
- package/src/site.ts +2 -0
- package/src/usage.ts +13 -9
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +10 -3
- package/templates/openapi.json +460 -1
- package/templates/src/README.md +37 -0
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/register.spec.ts +22 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/rbac-solution-perms.spec.ts +48 -3
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +8 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- 方言覆盖(postgres):INSERT OR IGNORE → ON CONFLICT DO NOTHING,语句与基础件逐条对应。
|
|
2
|
+
-- home 模块种子(P4-1)。按分号朴素切分(S006)。
|
|
3
|
+
-- home_pie:五分类(code 对齐前端 DATA_KEY),幂等 INSERT OR IGNORE。
|
|
4
|
+
INSERT INTO home_pie (category, value) VALUES ('electronics', 320) ON CONFLICT DO NOTHING;
|
|
5
|
+
INSERT INTO home_pie (category, value) VALUES ('home_goods', 210) ON CONFLICT DO NOTHING;
|
|
6
|
+
INSERT INTO home_pie (category, value) VALUES ('apparel_accessories', 180) ON CONFLICT DO NOTHING;
|
|
7
|
+
INSERT INTO home_pie (category, value) VALUES ('food_beverages', 150) ON CONFLICT DO NOTHING;
|
|
8
|
+
INSERT INTO home_pie (category, value) VALUES ('beauty_skincare', 95) ON CONFLICT DO NOTHING;
|
|
9
|
+
|
|
10
|
+
-- home_line:日窗口序列(day = epoch-day,种子围绕"当前日期"生成,约 60 天窗口)。
|
|
11
|
+
-- 刻意缺 today-30 / today-12 两天,演示"缺哪天补哪天"——handler 在窗口内对缺失日补 0;
|
|
12
|
+
-- 若整个窗口无 seed(seed 随时间变旧),handler 回落确定式趋势兜底(懒补数)。
|
|
13
|
+
INSERT INTO home_line (day, value) VALUES (20640, 181) ON CONFLICT DO NOTHING;
|
|
14
|
+
INSERT INTO home_line (day, value) VALUES (20641, 190) ON CONFLICT DO NOTHING;
|
|
15
|
+
INSERT INTO home_line (day, value) VALUES (20642, 199) ON CONFLICT DO NOTHING;
|
|
16
|
+
INSERT INTO home_line (day, value) VALUES (20643, 208) ON CONFLICT DO NOTHING;
|
|
17
|
+
INSERT INTO home_line (day, value) VALUES (20644, 217) ON CONFLICT DO NOTHING;
|
|
18
|
+
INSERT INTO home_line (day, value) VALUES (20645, 226) ON CONFLICT DO NOTHING;
|
|
19
|
+
INSERT INTO home_line (day, value) VALUES (20646, 235) ON CONFLICT DO NOTHING;
|
|
20
|
+
INSERT INTO home_line (day, value) VALUES (20647, 244) ON CONFLICT DO NOTHING;
|
|
21
|
+
INSERT INTO home_line (day, value) VALUES (20648, 253) ON CONFLICT DO NOTHING;
|
|
22
|
+
INSERT INTO home_line (day, value) VALUES (20649, 262) ON CONFLICT DO NOTHING;
|
|
23
|
+
INSERT INTO home_line (day, value) VALUES (20650, 271) ON CONFLICT DO NOTHING;
|
|
24
|
+
INSERT INTO home_line (day, value) VALUES (20651, 280) ON CONFLICT DO NOTHING;
|
|
25
|
+
INSERT INTO home_line (day, value) VALUES (20652, 289) ON CONFLICT DO NOTHING;
|
|
26
|
+
INSERT INTO home_line (day, value) VALUES (20653, 298) ON CONFLICT DO NOTHING;
|
|
27
|
+
INSERT INTO home_line (day, value) VALUES (20654, 100) ON CONFLICT DO NOTHING;
|
|
28
|
+
INSERT INTO home_line (day, value) VALUES (20655, 109) ON CONFLICT DO NOTHING;
|
|
29
|
+
INSERT INTO home_line (day, value) VALUES (20656, 118) ON CONFLICT DO NOTHING;
|
|
30
|
+
INSERT INTO home_line (day, value) VALUES (20657, 127) ON CONFLICT DO NOTHING;
|
|
31
|
+
INSERT INTO home_line (day, value) VALUES (20658, 136) ON CONFLICT DO NOTHING;
|
|
32
|
+
INSERT INTO home_line (day, value) VALUES (20659, 145) ON CONFLICT DO NOTHING;
|
|
33
|
+
INSERT INTO home_line (day, value) VALUES (20660, 154) ON CONFLICT DO NOTHING;
|
|
34
|
+
INSERT INTO home_line (day, value) VALUES (20661, 163) ON CONFLICT DO NOTHING;
|
|
35
|
+
INSERT INTO home_line (day, value) VALUES (20662, 172) ON CONFLICT DO NOTHING;
|
|
36
|
+
INSERT INTO home_line (day, value) VALUES (20663, 181) ON CONFLICT DO NOTHING;
|
|
37
|
+
INSERT INTO home_line (day, value) VALUES (20664, 190) ON CONFLICT DO NOTHING;
|
|
38
|
+
INSERT INTO home_line (day, value) VALUES (20665, 199) ON CONFLICT DO NOTHING;
|
|
39
|
+
INSERT INTO home_line (day, value) VALUES (20666, 208) ON CONFLICT DO NOTHING;
|
|
40
|
+
INSERT INTO home_line (day, value) VALUES (20667, 217) ON CONFLICT DO NOTHING;
|
|
41
|
+
INSERT INTO home_line (day, value) VALUES (20668, 226) ON CONFLICT DO NOTHING;
|
|
42
|
+
INSERT INTO home_line (day, value) VALUES (20669, 235) ON CONFLICT DO NOTHING;
|
|
43
|
+
INSERT INTO home_line (day, value) VALUES (20671, 253) ON CONFLICT DO NOTHING;
|
|
44
|
+
INSERT INTO home_line (day, value) VALUES (20672, 262) ON CONFLICT DO NOTHING;
|
|
45
|
+
INSERT INTO home_line (day, value) VALUES (20673, 271) ON CONFLICT DO NOTHING;
|
|
46
|
+
INSERT INTO home_line (day, value) VALUES (20674, 280) ON CONFLICT DO NOTHING;
|
|
47
|
+
INSERT INTO home_line (day, value) VALUES (20675, 289) ON CONFLICT DO NOTHING;
|
|
48
|
+
INSERT INTO home_line (day, value) VALUES (20676, 298) ON CONFLICT DO NOTHING;
|
|
49
|
+
INSERT INTO home_line (day, value) VALUES (20677, 100) ON CONFLICT DO NOTHING;
|
|
50
|
+
INSERT INTO home_line (day, value) VALUES (20678, 109) ON CONFLICT DO NOTHING;
|
|
51
|
+
INSERT INTO home_line (day, value) VALUES (20679, 118) ON CONFLICT DO NOTHING;
|
|
52
|
+
INSERT INTO home_line (day, value) VALUES (20680, 127) ON CONFLICT DO NOTHING;
|
|
53
|
+
INSERT INTO home_line (day, value) VALUES (20681, 136) ON CONFLICT DO NOTHING;
|
|
54
|
+
INSERT INTO home_line (day, value) VALUES (20682, 145) ON CONFLICT DO NOTHING;
|
|
55
|
+
INSERT INTO home_line (day, value) VALUES (20683, 154) ON CONFLICT DO NOTHING;
|
|
56
|
+
INSERT INTO home_line (day, value) VALUES (20684, 163) ON CONFLICT DO NOTHING;
|
|
57
|
+
INSERT INTO home_line (day, value) VALUES (20685, 172) ON CONFLICT DO NOTHING;
|
|
58
|
+
INSERT INTO home_line (day, value) VALUES (20686, 181) ON CONFLICT DO NOTHING;
|
|
59
|
+
INSERT INTO home_line (day, value) VALUES (20687, 190) ON CONFLICT DO NOTHING;
|
|
60
|
+
INSERT INTO home_line (day, value) VALUES (20689, 208) ON CONFLICT DO NOTHING;
|
|
61
|
+
INSERT INTO home_line (day, value) VALUES (20690, 217) ON CONFLICT DO NOTHING;
|
|
62
|
+
INSERT INTO home_line (day, value) VALUES (20691, 226) ON CONFLICT DO NOTHING;
|
|
63
|
+
INSERT INTO home_line (day, value) VALUES (20692, 235) ON CONFLICT DO NOTHING;
|
|
64
|
+
INSERT INTO home_line (day, value) VALUES (20693, 244) ON CONFLICT DO NOTHING;
|
|
65
|
+
INSERT INTO home_line (day, value) VALUES (20694, 253) ON CONFLICT DO NOTHING;
|
|
66
|
+
INSERT INTO home_line (day, value) VALUES (20695, 262) ON CONFLICT DO NOTHING;
|
|
67
|
+
INSERT INTO home_line (day, value) VALUES (20696, 271) ON CONFLICT DO NOTHING;
|
|
68
|
+
INSERT INTO home_line (day, value) VALUES (20697, 280) ON CONFLICT DO NOTHING;
|
|
69
|
+
INSERT INTO home_line (day, value) VALUES (20698, 289) ON CONFLICT DO NOTHING;
|
|
70
|
+
INSERT INTO home_line (day, value) VALUES (20699, 298) ON CONFLICT DO NOTHING;
|
|
71
|
+
INSERT INTO home_line (day, value) VALUES (20700, 100) ON CONFLICT DO NOTHING;
|
|
@@ -69,11 +69,9 @@ function splitId(id: string): [scope: "u" | "b", raw: string] {
|
|
|
69
69
|
function mapItem(r: Row): NoticeItem {
|
|
70
70
|
const scope = String(r.scope);
|
|
71
71
|
const isBroadcast = scope === "broadcast";
|
|
72
|
-
// UNION 结果列名取首分支(userQ)=> 位置 4 名为 is_read
|
|
73
|
-
//
|
|
74
|
-
const isRead =
|
|
75
|
-
? (r.is_read != null && String(r.is_read) !== "")
|
|
76
|
-
: Number(r.is_read) === 1;
|
|
72
|
+
// UNION 结果列名取首分支(userQ)=> 位置 4 名为 is_read,两分支都输出整数 0/1
|
|
73
|
+
// (按人项取存储列,广播项由回执 EXISTS 推导),故统一按整数判已读。
|
|
74
|
+
const isRead = Number(r.is_read) === 1;
|
|
77
75
|
return {
|
|
78
76
|
id: (isBroadcast ? "b:" : "u:") + String(r.id),
|
|
79
77
|
scope: isBroadcast ? "broadcast" : "user",
|
|
@@ -172,7 +170,9 @@ export async function noticesFor(
|
|
|
172
170
|
"b.id",
|
|
173
171
|
"b.avatar",
|
|
174
172
|
"b.date",
|
|
175
|
-
|
|
173
|
+
// 第 4 列与首分支 is_read 同型(整数):pg 的 UNION 不允许 integer/text 混排,
|
|
174
|
+
// sqlite/mysql 会隐式放宽;回执存在即已读 → 0/1,mapItem 统一按 Number(...) === 1 判定。
|
|
175
|
+
{ case: { when: [{ cond: { field: "r.read_time", op: "isnull" }, then: 0 }], else: 1 }, as: "is_read" },
|
|
176
176
|
"b.message",
|
|
177
177
|
"b.title",
|
|
178
178
|
"b.link",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 0001(mysql 方言):通知表建表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGINT AUTO_INCREMENT;TEXT 不能带字面默认值 → 短文本收窄 VARCHAR,长文本用括号默认值。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
4
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
5
|
+
avatar VARCHAR(255) NOT NULL DEFAULT '',
|
|
6
|
+
`date` VARCHAR(64) NOT NULL DEFAULT '',
|
|
7
|
+
is_read INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
message TEXT NOT NULL DEFAULT (''),
|
|
9
|
+
title VARCHAR(255) NOT NULL DEFAULT ''
|
|
10
|
+
);
|
|
11
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 0001(postgres 方言):通知表建表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGSERIAL(SERIAL 系允许 seed 显式写 id);TEXT 在 pg 合法且可带默认值。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
4
|
+
id BIGSERIAL PRIMARY KEY,
|
|
5
|
+
avatar TEXT NOT NULL DEFAULT '',
|
|
6
|
+
date TEXT NOT NULL DEFAULT '',
|
|
7
|
+
is_read INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
message TEXT NOT NULL DEFAULT '',
|
|
9
|
+
title TEXT NOT NULL DEFAULT ''
|
|
10
|
+
);
|
|
11
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- 0002(mysql 方言):按人真实通知表演进。
|
|
2
|
+
-- 方言差异:TEXT 不能带字面默认值 → category 收窄 VARCHAR(255);MySQL 不支持 CREATE INDEX IF NOT EXISTS,直接建索引。
|
|
3
|
+
ALTER TABLE notifications ADD COLUMN user_id INTEGER;
|
|
4
|
+
ALTER TABLE notifications ADD COLUMN link TEXT;
|
|
5
|
+
ALTER TABLE notifications ADD COLUMN category VARCHAR(255) NOT NULL DEFAULT 'notice';
|
|
6
|
+
CREATE INDEX idx_notifications_user ON notifications (user_id, is_read);
|
|
7
|
+
UPDATE notifications SET user_id = 1, category = 'notice' WHERE user_id IS NULL;
|
|
8
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- 0003(mysql 方言):广播表 + 已读回执表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGINT AUTO_INCREMENT;CREATE INDEX IF NOT EXISTS 不被 MySQL 支持,索引写进建表语句。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast (
|
|
4
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
5
|
+
avatar VARCHAR(255) NOT NULL DEFAULT '',
|
|
6
|
+
`date` VARCHAR(64) NOT NULL DEFAULT '',
|
|
7
|
+
message TEXT NOT NULL DEFAULT (''),
|
|
8
|
+
title VARCHAR(255) NOT NULL DEFAULT '',
|
|
9
|
+
link TEXT,
|
|
10
|
+
category VARCHAR(255) NOT NULL DEFAULT 'notice',
|
|
11
|
+
create_time VARCHAR(64) NOT NULL DEFAULT ''
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast_read (
|
|
15
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
16
|
+
broadcast_id BIGINT NOT NULL,
|
|
17
|
+
user_id BIGINT NOT NULL,
|
|
18
|
+
read_time VARCHAR(64) NOT NULL DEFAULT '',
|
|
19
|
+
KEY idx_broadcast_read (user_id, broadcast_id)
|
|
20
|
+
);
|
|
21
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- 0003(postgres 方言):广播表 + 已读回执表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGSERIAL(SERIAL 系允许 seed 显式写 id);其余 DDL 与基础件同语法。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast (
|
|
4
|
+
id BIGSERIAL PRIMARY KEY,
|
|
5
|
+
avatar TEXT NOT NULL DEFAULT '',
|
|
6
|
+
date TEXT NOT NULL DEFAULT '',
|
|
7
|
+
message TEXT NOT NULL DEFAULT '',
|
|
8
|
+
title TEXT NOT NULL DEFAULT '',
|
|
9
|
+
link TEXT,
|
|
10
|
+
category TEXT NOT NULL DEFAULT 'notice',
|
|
11
|
+
create_time TEXT NOT NULL DEFAULT ''
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast_read (
|
|
15
|
+
id BIGSERIAL PRIMARY KEY,
|
|
16
|
+
broadcast_id BIGINT NOT NULL,
|
|
17
|
+
user_id BIGINT NOT NULL,
|
|
18
|
+
read_time TEXT NOT NULL DEFAULT ''
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_broadcast_read ON notifications_broadcast_read (user_id, broadcast_id);
|
|
22
|
+
|
package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- 0004(mysql 方言):广播隐藏标记表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGINT AUTO_INCREMENT;CREATE INDEX IF NOT EXISTS 不被 MySQL 支持,索引写进建表语句。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast_hidden (
|
|
4
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
5
|
+
broadcast_id BIGINT NOT NULL,
|
|
6
|
+
user_id BIGINT NOT NULL,
|
|
7
|
+
create_time VARCHAR(64) NOT NULL DEFAULT '',
|
|
8
|
+
KEY idx_broadcast_hidden (user_id, broadcast_id)
|
|
9
|
+
);
|
|
10
|
+
|
package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 0004(postgres 方言):广播隐藏标记表。口径同基础件。
|
|
2
|
+
-- 方言差异:AUTOINCREMENT → BIGSERIAL(SERIAL 系允许 seed 显式写 id);其余 DDL 与基础件同语法。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS notifications_broadcast_hidden (
|
|
4
|
+
id BIGSERIAL PRIMARY KEY,
|
|
5
|
+
broadcast_id BIGINT NOT NULL,
|
|
6
|
+
user_id BIGINT NOT NULL,
|
|
7
|
+
create_time TEXT NOT NULL DEFAULT ''
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
CREATE INDEX IF NOT EXISTS idx_broadcast_hidden ON notifications_broadcast_hidden (user_id, broadcast_id);
|
|
11
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0005__tenant(postgres 方言):通知四表租户化。TEXT 在 pg 合法,与基础件同语法。
|
|
2
|
+
ALTER TABLE notifications ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
3
|
+
ALTER TABLE notifications_broadcast ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
4
|
+
ALTER TABLE notifications_broadcast_read ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
5
|
+
ALTER TABLE notifications_broadcast_hidden ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
|
|
6
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- 方言覆盖(mysql):INSERT OR IGNORE → INSERT IGNORE,语句与基础件逐条对应。
|
|
2
|
+
-- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
|
|
3
|
+
-- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
|
|
4
|
+
-- 按分号朴素切分(S006):语句内不得含分号字面量。
|
|
5
|
+
INSERT IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / aA123456 登录开始', '欢迎', NULL, 'notice');
|
|
6
|
+
INSERT IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, 'https://avatar.vercel.sh/seed-bell.svg?text=B', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
|
|
7
|
+
INSERT IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, 'https://avatar.vercel.sh/seed-contract.svg?text=C', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
|
|
8
|
+
INSERT IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, 'https://avatar.vercel.sh/seed-dev.svg?text=D', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- 方言覆盖(postgres):INSERT OR IGNORE → ON CONFLICT DO NOTHING,语句与基础件逐条对应。
|
|
2
|
+
-- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
|
|
3
|
+
-- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
|
|
4
|
+
-- 按分号朴素切分(S006):语句内不得含分号字面量。
|
|
5
|
+
INSERT INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / aA123456 登录开始', '欢迎', NULL, 'notice') ON CONFLICT DO NOTHING;
|
|
6
|
+
INSERT INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, 'https://avatar.vercel.sh/seed-bell.svg?text=B', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice') ON CONFLICT DO NOTHING;
|
|
7
|
+
INSERT INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, 'https://avatar.vercel.sh/seed-contract.svg?text=C', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice') ON CONFLICT DO NOTHING;
|
|
8
|
+
INSERT INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, 'https://avatar.vercel.sh/seed-dev.svg?text=D', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice') ON CONFLICT DO NOTHING;
|
|
9
|
+
|
|
10
|
+
-- 显式写 id 不会推进 BIGSERIAL 序列,补一次 setval 免新增行撞主键(幂等,每次启动按当前最大 id 校正)。
|
|
11
|
+
SELECT setval(pg_get_serial_sequence('notifications', 'id'), GREATEST((SELECT MAX(id) FROM notifications), 1));
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 发布身份的推导规则 —— **唯一真源**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么只在服务端算:`key_id` 直接决定 `~/.ojm/keys/<id>/` 的目录名与签名身份,
|
|
4
|
+
// 同一规则若在 `packages/cli` 与模板工程(互不可 import 的两棵树)各写一份,必然漂移,
|
|
5
|
+
// 表现为「CLI 建了 demo-solution.wecom-everpan,服务端记录成 demo-solution.wecom-everpan-x」。
|
|
6
|
+
// 故 key_id 在**申请时**由本文件算好落库,兑换接口原样下发,CLI 零推导。
|
|
7
|
+
//
|
|
8
|
+
// 规则与约束:
|
|
9
|
+
// · 产品名 = 解决方案名,遵循 NAME_RE(小写字母开头,仅 [a-z0-9-],≤64)
|
|
10
|
+
// · 人名段 = username 归一化:小写 → 非 [a-z0-9-] 一律替换为 '-' → 折叠连续 '-' → 去首尾 '-'
|
|
11
|
+
// (`wecom:everpan` → `wecom-everpan`,目录名不出现冒号;比对也无需回到裸人名)
|
|
12
|
+
// · key_id = `<product>.<person>`,必须满足 C2-37 的 key_id 形状
|
|
13
|
+
// · 归一化后为空(例如纯中文用户名)→ 退化为 `u<uid>`,保证仍满足 key_id 形状
|
|
14
|
+
|
|
15
|
+
/** C2-37 key_id 形状:小写、≥2 段、仅 [a-z0-9-](与 packages/cli/src/keygen.ts 的 isValidKeyId 同规则) */
|
|
16
|
+
export const KEY_ID_RE = /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)+$/;
|
|
17
|
+
|
|
18
|
+
/** 解决方案名规则(与 packages/cli/src/manifest-contract.ts 的 NAME_RE 同规则) */
|
|
19
|
+
export const PRODUCT_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 保留名:产品名占用它们会让人误以为是框架/平台自身发布的包。
|
|
23
|
+
* 与 manifest-contract 的 RESERVED_NAMES 取交集(`_platform` / `_system` 等带下划线的
|
|
24
|
+
* 本就过不了 PRODUCT_RE,不必重复列)。
|
|
25
|
+
*/
|
|
26
|
+
export const RESERVED_PRODUCTS = new Set(["oj", "ojm", "system", "auth", "market", "framework", "health", "plugins", "registry"]);
|
|
27
|
+
|
|
28
|
+
/** 凭证有效期:7 天(一次性令牌的暴露窗口) */
|
|
29
|
+
export const CREDENTIAL_TTL_SECS = 7 * 24 * 3600;
|
|
30
|
+
|
|
31
|
+
export interface IdentityInput {
|
|
32
|
+
product: string
|
|
33
|
+
username: string
|
|
34
|
+
uid: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** username → key_id 的人名段 */
|
|
38
|
+
export function encodePerson(username: string, uid: number): string {
|
|
39
|
+
const normalized = username
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
42
|
+
.replace(/-+/g, "-")
|
|
43
|
+
.replace(/^-|-$/g, "");
|
|
44
|
+
return normalized === "" ? `u${uid}` : normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 产品名 + 用户名 → key_id(不做校验,调用方先用 assertProduct 收口) */
|
|
48
|
+
export function deriveKeyId(input: IdentityInput): string {
|
|
49
|
+
return `${input.product}.${encodePerson(input.username, input.uid)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 产品名校验:形状 + 保留名。错误信息给可用示例,避免用户反复试错 */
|
|
53
|
+
export function assertProduct(value: unknown): string {
|
|
54
|
+
const product = typeof value === "string" ? value.trim() : "";
|
|
55
|
+
if (product === "")
|
|
56
|
+
throw Object.assign(new Error("产品名不能为空:填你要发布的解决方案名,例如 demo-solution"), { status: 400 });
|
|
57
|
+
if (!PRODUCT_RE.test(product))
|
|
58
|
+
throw Object.assign(new Error("产品名只能是小写字母开头的 [a-z0-9-],最长 64 个字符,例如 demo-solution"), { status: 400 });
|
|
59
|
+
if (RESERVED_PRODUCTS.has(product))
|
|
60
|
+
throw Object.assign(new Error(`产品名 ${product} 是框架保留名,请换成你的解决方案名`), { status: 400 });
|
|
61
|
+
return product;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 派生结果自检:理论上必然成立,不成立说明归一化有 bug,宁可 500 也不发一个畸形身份 */
|
|
65
|
+
export function assertKeyId(keyId: string): string {
|
|
66
|
+
if (!KEY_ID_RE.test(keyId))
|
|
67
|
+
throw new Error(`内部错误:推导出的发布身份 ${keyId} 不符合 key_id 规则,请联系管理员`);
|
|
68
|
+
return keyId;
|
|
69
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// 发布凭证端点族:/api/publish/credential/*
|
|
2
|
+
//
|
|
3
|
+
// 职责边界(ISP):本文件只做「门禁 + 入参收口 + 一次原子读写」;
|
|
4
|
+
// 身份推导在 _shared/identity.ts(纯函数,无 IO),凭证换身份 = 一条条件 UPDATE 抢所有权。
|
|
5
|
+
//
|
|
6
|
+
// 三个门径的差别是刻意的:
|
|
7
|
+
// · 申请 / 列表 / 撤销:登录态(currentAccess 已含「伪造租户头即查无此人」的拦截)
|
|
8
|
+
// · 兑换:**匿名**。凭证自身就是持有型证明——CLI 可能没有账号密码,
|
|
9
|
+
// 这是它唯一需要的能力。抗穷举靠 256 位随机 + IP 维度限速,不靠登录态。
|
|
10
|
+
//
|
|
11
|
+
// 为什么是工厂(createPublishApi)而不是裸 handler:随机数与时钟必须可注入,
|
|
12
|
+
// 否则「过期」这类用例只能靠 sleep,「并发双花」也无法稳定构造。
|
|
13
|
+
// 与 market/api.ts 的 createMarketApi(marketEnv()) 同一形态。
|
|
14
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
15
|
+
import { currentAccess } from "#/_platform/_shared/service/infra/access";
|
|
16
|
+
import { pagedFactory } from "#/_platform/_shared/service/infra/query";
|
|
17
|
+
import { rateLimit } from "#/_platform/_shared/service/infra/rate";
|
|
18
|
+
import { assertKeyId, assertProduct, CREDENTIAL_TTL_SECS, deriveKeyId } from "./_shared/identity";
|
|
19
|
+
|
|
20
|
+
/** 兑换失败的唯一文案:不区分「不存在 / 已兑换 / 已过期 / 已撤销」,避免探测,且给出下一步 */
|
|
21
|
+
const EXCHANGE_INVALID = "发布凭证无效或已失效(可能已兑换、已过期或已撤销)。请到「发布管理」页重新申请";
|
|
22
|
+
|
|
23
|
+
/** 单 IP 兑换尝试上限(每 10 分钟);256 位随机下正常用户远用不到,超限只可能是扫 */
|
|
24
|
+
const EXCHANGE_RATE_LIMIT = 20;
|
|
25
|
+
const EXCHANGE_RATE_WINDOW_MS = 10 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export interface PublishDeps {
|
|
28
|
+
/** 秒级时钟 */
|
|
29
|
+
now: () => number
|
|
30
|
+
randomHex: (bytes: number) => string
|
|
31
|
+
sha256Hex: (input: string) => string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fail(status: number, message: string): never {
|
|
35
|
+
throw Object.assign(new Error(message), { status });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 客户端 IP:反代场景读 x-forwarded-for 首段(与 login-throttle 同口径) */
|
|
39
|
+
function clientIp(): string {
|
|
40
|
+
const headers = (http.headers ?? {}) as Record<string, string>;
|
|
41
|
+
return String(headers["x-forwarded-for"] ?? headers["x-real-ip"] ?? "unknown").split(",")[0].trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 运行时依赖:oj isolate 的 crypto 增补面(无 randomUUID/sha256 时需要显式报缺,不静默降级) */
|
|
45
|
+
export function runtimePublishDeps(): PublishDeps {
|
|
46
|
+
return {
|
|
47
|
+
now: () => Math.floor(Date.now() / 1000),
|
|
48
|
+
randomHex: (n) => {
|
|
49
|
+
const c = (globalThis as unknown as { crypto?: { randomHex?: (bytes: number) => string } }).crypto;
|
|
50
|
+
if (typeof c?.randomHex !== "function")
|
|
51
|
+
throw new Error("publish: 运行时缺少 crypto.randomHex,无法签发凭证");
|
|
52
|
+
return String(c.randomHex(n));
|
|
53
|
+
},
|
|
54
|
+
sha256Hex: (input) => {
|
|
55
|
+
const c = (globalThis as unknown as { crypto?: { sha256Hex?: (s: string) => string } }).crypto;
|
|
56
|
+
if (typeof c?.sha256Hex !== "function")
|
|
57
|
+
throw new Error("publish: 运行时缺少 crypto.sha256Hex,无法签发凭证");
|
|
58
|
+
return String(c.sha256Hex(input));
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Actor {
|
|
64
|
+
uid: number
|
|
65
|
+
username: string
|
|
66
|
+
displayName: string | null
|
|
67
|
+
tenant: string
|
|
68
|
+
superadmin: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface CredentialRow {
|
|
72
|
+
id: number
|
|
73
|
+
product: string
|
|
74
|
+
key_id: string
|
|
75
|
+
username: string
|
|
76
|
+
display_name: string | null
|
|
77
|
+
owner_tenant: string
|
|
78
|
+
token_hint: string
|
|
79
|
+
expires_at: number
|
|
80
|
+
used: number
|
|
81
|
+
used_at: number | null
|
|
82
|
+
revoked: number
|
|
83
|
+
created_at: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 状态由服务端判定(前端各自推导必然分叉) */
|
|
87
|
+
function statusOf(row: CredentialRow, now: number): "active" | "used" | "revoked" | "expired" {
|
|
88
|
+
if (Number(row.used) === 1)
|
|
89
|
+
return "used";
|
|
90
|
+
if (Number(row.revoked) === 1)
|
|
91
|
+
return "revoked";
|
|
92
|
+
if (Number(row.expires_at) <= now)
|
|
93
|
+
return "expired";
|
|
94
|
+
return "active";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createPublishApi(deps: PublishDeps) {
|
|
98
|
+
/** 当前操作者:身份来自 token,租户与用户名来自**当前租户下**的 users 行(伪造头即查无此行) */
|
|
99
|
+
async function actor(): Promise<Actor> {
|
|
100
|
+
const snap = await currentAccess();
|
|
101
|
+
const uid = Number(http.user?.id);
|
|
102
|
+
const rows = await db.table("users")
|
|
103
|
+
.select(["username", "display_name", "tenant_id"])
|
|
104
|
+
.where({ field: "id", op: "eq", value: uid })
|
|
105
|
+
.all() as Array<Record<string, unknown>>;
|
|
106
|
+
const row = rows[0];
|
|
107
|
+
if (!row)
|
|
108
|
+
fail(403, "forbidden");
|
|
109
|
+
return {
|
|
110
|
+
uid,
|
|
111
|
+
username: String(row.username),
|
|
112
|
+
displayName: row.display_name == null ? null : String(row.display_name),
|
|
113
|
+
tenant: String(row.tenant_id ?? "default"),
|
|
114
|
+
superadmin: snap.roles.includes("superadmin"),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── GET /api/publish/credential —— 列表(本人;superadmin 可看全部并按租户筛)────────
|
|
119
|
+
const get = async () => {
|
|
120
|
+
await respond(async () => {
|
|
121
|
+
const me = await actor();
|
|
122
|
+
const q = (http.query ?? {}) as Record<string, unknown>;
|
|
123
|
+
const tenantFilter = me.superadmin && q.tenant ? String(q.tenant) : undefined;
|
|
124
|
+
|
|
125
|
+
// 共享表:构造器不会注入租户谓词,隔离条件必须显式写
|
|
126
|
+
const mk = () => {
|
|
127
|
+
const b = db.table("publish_credential")
|
|
128
|
+
.whereIf(tenantFilter, { field: "owner_tenant", op: "eq", value: tenantFilter });
|
|
129
|
+
if (me.superadmin)
|
|
130
|
+
return b;
|
|
131
|
+
return b.where({ field: "owner_tenant", op: "eq", value: me.tenant })
|
|
132
|
+
.where({ field: "uid", op: "eq", value: me.uid });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const r = await pagedFactory<CredentialRow>(mk, { page: q.page, size: q.limit });
|
|
136
|
+
const now = deps.now();
|
|
137
|
+
return {
|
|
138
|
+
list: r.list.map(row => ({
|
|
139
|
+
id: Number(row.id),
|
|
140
|
+
product: row.product,
|
|
141
|
+
key_id: row.key_id,
|
|
142
|
+
username: row.username,
|
|
143
|
+
display_name: row.display_name,
|
|
144
|
+
token_hint: row.token_hint,
|
|
145
|
+
status: statusOf(row, now),
|
|
146
|
+
expires_at: Number(row.expires_at),
|
|
147
|
+
used_at: row.used_at == null ? null : Number(row.used_at),
|
|
148
|
+
created_at: Number(row.created_at),
|
|
149
|
+
// 归属租户只对超管有意义
|
|
150
|
+
owner_tenant: me.superadmin ? row.owner_tenant : undefined,
|
|
151
|
+
})),
|
|
152
|
+
total: r.total,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
get.schema = {
|
|
157
|
+
query: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
page: { type: "number", minimum: 1 },
|
|
161
|
+
limit: { type: "number", minimum: 1, maximum: 200 },
|
|
162
|
+
tenant: { type: "string" },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
get.response = {
|
|
167
|
+
type: "object",
|
|
168
|
+
required: ["list", "total"],
|
|
169
|
+
properties: {
|
|
170
|
+
list: {
|
|
171
|
+
type: "array",
|
|
172
|
+
items: {
|
|
173
|
+
type: "object",
|
|
174
|
+
required: ["id", "product", "key_id", "username", "token_hint", "status", "expires_at", "created_at"],
|
|
175
|
+
properties: {
|
|
176
|
+
id: { type: "number" },
|
|
177
|
+
product: { type: "string" },
|
|
178
|
+
key_id: { type: "string" },
|
|
179
|
+
username: { type: "string" },
|
|
180
|
+
display_name: { type: ["string", "null"] },
|
|
181
|
+
token_hint: { type: "string" },
|
|
182
|
+
status: { type: "string", enum: ["active", "used", "revoked", "expired"] },
|
|
183
|
+
expires_at: { type: "number" },
|
|
184
|
+
used_at: { type: ["number", "null"] },
|
|
185
|
+
created_at: { type: "number" },
|
|
186
|
+
owner_tenant: { type: "string" },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
total: { type: "number" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ── POST /api/publish/credential —— 申请(填产品名 → 一次性拿到凭证 + 已定身份)─────
|
|
195
|
+
const post = async () => {
|
|
196
|
+
await respond(async () => {
|
|
197
|
+
const body = (http.body ?? {}) as Record<string, unknown>;
|
|
198
|
+
const product = assertProduct(body.product);
|
|
199
|
+
const me = await actor();
|
|
200
|
+
const keyId = assertKeyId(deriveKeyId({ product, username: me.username, uid: me.uid }));
|
|
201
|
+
|
|
202
|
+
// 撞号:同一 key_id 已被别人占用 → 拒绝。同一人重复申请是允许的
|
|
203
|
+
// (密钥长期有效,换机重建属正常操作),故只拦 uid 不同。
|
|
204
|
+
const dup = await db.table("publish_credential")
|
|
205
|
+
.select(["uid"])
|
|
206
|
+
.where({ field: "key_id", op: "eq", value: keyId })
|
|
207
|
+
.limit(1)
|
|
208
|
+
.all() as Array<Record<string, unknown>>;
|
|
209
|
+
if (dup.length > 0 && Number(dup[0].uid) !== me.uid)
|
|
210
|
+
fail(409, `发布身份 ${keyId} 已被其他用户占用,请换一个产品名`);
|
|
211
|
+
|
|
212
|
+
const token = deps.randomHex(32);
|
|
213
|
+
const now = deps.now();
|
|
214
|
+
const expiresAt = now + CREDENTIAL_TTL_SECS;
|
|
215
|
+
await db.table("publish_credential").insert({
|
|
216
|
+
token_hash: deps.sha256Hex(token),
|
|
217
|
+
token_hint: token.slice(0, 8),
|
|
218
|
+
uid: me.uid,
|
|
219
|
+
username: me.username,
|
|
220
|
+
display_name: me.displayName,
|
|
221
|
+
product,
|
|
222
|
+
key_id: keyId,
|
|
223
|
+
owner_tenant: me.tenant,
|
|
224
|
+
expires_at: expiresAt,
|
|
225
|
+
used: 0,
|
|
226
|
+
revoked: 0,
|
|
227
|
+
created_at: now,
|
|
228
|
+
created_ip: clientIp(),
|
|
229
|
+
}).run();
|
|
230
|
+
|
|
231
|
+
// 明文只在这里出现一次;库里只有 sha256
|
|
232
|
+
return { token, key_id: keyId, product, token_hint: token.slice(0, 8), expires_at: expiresAt };
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
post.schema = {
|
|
236
|
+
body: {
|
|
237
|
+
type: "object",
|
|
238
|
+
required: ["product"],
|
|
239
|
+
properties: { product: { type: "string" } },
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
post.response = {
|
|
243
|
+
type: "object",
|
|
244
|
+
required: ["token", "key_id", "product", "expires_at"],
|
|
245
|
+
properties: {
|
|
246
|
+
token: { type: "string" },
|
|
247
|
+
key_id: { type: "string" },
|
|
248
|
+
product: { type: "string" },
|
|
249
|
+
token_hint: { type: "string" },
|
|
250
|
+
expires_at: { type: "number" },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// ── POST /api/publish/credential/revoke —— 撤销(本人或超管;已兑换的不可撤销)──────
|
|
255
|
+
const revoke = async () => {
|
|
256
|
+
await respond(async () => {
|
|
257
|
+
const body = (http.body ?? {}) as Record<string, unknown>;
|
|
258
|
+
const id = Number(body.id);
|
|
259
|
+
if (!Number.isInteger(id) || id <= 0)
|
|
260
|
+
fail(400, "id 必须是正整数");
|
|
261
|
+
const me = await actor();
|
|
262
|
+
|
|
263
|
+
const rows = await db.table("publish_credential")
|
|
264
|
+
.select(["id", "uid", "owner_tenant", "used", "revoked"])
|
|
265
|
+
.where({ field: "id", op: "eq", value: id })
|
|
266
|
+
.all() as Array<Record<string, unknown>>;
|
|
267
|
+
const row = rows[0];
|
|
268
|
+
// 越权与不存在同一句话:不给探测者区分「存在但不属于你」的材料
|
|
269
|
+
if (!row || (!me.superadmin && (Number(row.uid) !== me.uid || String(row.owner_tenant) !== me.tenant)))
|
|
270
|
+
fail(404, "凭证不存在");
|
|
271
|
+
|
|
272
|
+
if (Number(row.used) === 1)
|
|
273
|
+
fail(400, "该凭证已兑换成发布身份,不能撤销(发布身份长期有效;如需停用请移除对应信任公钥)");
|
|
274
|
+
if (Number(row.revoked) === 1)
|
|
275
|
+
return { ok: true };
|
|
276
|
+
|
|
277
|
+
await db.table("publish_credential").update({ revoked: 1 }).where({ field: "id", op: "eq", value: id }).run();
|
|
278
|
+
return { ok: true };
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
revoke.schema = {
|
|
282
|
+
body: { type: "object", required: ["id"], properties: { id: { type: "number" } } },
|
|
283
|
+
};
|
|
284
|
+
revoke.response = { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } };
|
|
285
|
+
|
|
286
|
+
// ── POST /api/publish/credential/exchange —— 兑换(匿名;一次性,原子抢所有权)──────
|
|
287
|
+
const exchange = async () => {
|
|
288
|
+
await respond(async () => {
|
|
289
|
+
// 抗穷举:单 IP 每 10 分钟最多 20 次尝试
|
|
290
|
+
await rateLimit(`publish-exchange:ip:${clientIp()}`, EXCHANGE_RATE_LIMIT, EXCHANGE_RATE_WINDOW_MS);
|
|
291
|
+
|
|
292
|
+
const body = (http.body ?? {}) as Record<string, unknown>;
|
|
293
|
+
const token = String(body.token ?? "").trim();
|
|
294
|
+
// 形状先挡:64 位 hex 以外的输入连查库都不必
|
|
295
|
+
if (!/^[0-9a-f]{64}$/i.test(token))
|
|
296
|
+
fail(400, EXCHANGE_INVALID);
|
|
297
|
+
|
|
298
|
+
const hash = deps.sha256Hex(token);
|
|
299
|
+
const now = deps.now();
|
|
300
|
+
// 一条语句完成「判有效 + 抢所有权」:并发双花只可能有一个 changes=1
|
|
301
|
+
const changed = Number(await db.table("publish_credential")
|
|
302
|
+
.update({ used: 1, used_at: now })
|
|
303
|
+
.where({
|
|
304
|
+
and: [
|
|
305
|
+
{ field: "token_hash", op: "eq", value: hash },
|
|
306
|
+
{ field: "used", op: "eq", value: 0 },
|
|
307
|
+
{ field: "revoked", op: "eq", value: 0 },
|
|
308
|
+
{ field: "expires_at", op: "gt", value: now },
|
|
309
|
+
],
|
|
310
|
+
})
|
|
311
|
+
.run());
|
|
312
|
+
if (changed !== 1)
|
|
313
|
+
fail(400, EXCHANGE_INVALID);
|
|
314
|
+
|
|
315
|
+
const rows = await db.table("publish_credential")
|
|
316
|
+
.select(["key_id", "product", "username", "display_name"])
|
|
317
|
+
.where({ field: "token_hash", op: "eq", value: hash })
|
|
318
|
+
.all() as Array<Record<string, unknown>>;
|
|
319
|
+
const row = rows[0];
|
|
320
|
+
if (!row)
|
|
321
|
+
fail(400, EXCHANGE_INVALID);
|
|
322
|
+
return {
|
|
323
|
+
key_id: String(row.key_id),
|
|
324
|
+
product: String(row.product),
|
|
325
|
+
username: String(row.username),
|
|
326
|
+
display_name: row.display_name == null ? null : String(row.display_name),
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
exchange.schema = {
|
|
331
|
+
body: { type: "object", required: ["token"], properties: { token: { type: "string" } } },
|
|
332
|
+
};
|
|
333
|
+
exchange.response = {
|
|
334
|
+
type: "object",
|
|
335
|
+
required: ["key_id", "product", "username"],
|
|
336
|
+
properties: {
|
|
337
|
+
key_id: { type: "string" },
|
|
338
|
+
product: { type: "string" },
|
|
339
|
+
username: { type: "string" },
|
|
340
|
+
display_name: { type: ["string", "null"] },
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
credential: { get, post, revoke: { post: revoke }, exchange: { post: exchange } },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default createPublishApi(runtimePublishDeps());
|