@oj-module/cli 0.2.67 → 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.
@@ -0,0 +1,37 @@
1
+ # src/ 目录说明(动手前必读)
2
+
3
+ 这个目录里有两类东西,规矩不一样。
4
+
5
+ ## 框架自带的模块(不许改)
6
+
7
+ - `src/app/` —— 框架内置页面与后端(登录、首页、个人中心、通知、系统管理、认证等)
8
+ - `src/oj-market/` —— 解决方案市场
9
+
10
+ 它们由上游统一维护、跟着框架版本走。**请不要修改、也不要删除这里的文件**:
11
+
12
+ - `ojm build` 打包前会逐文件核对这两个目录,发现与官方版本不一致(改过、删过、
13
+ 或多出文件)会**直接拒绝构建**,并列出差异清单。
14
+ - 为什么这么严:你今天改一行,下次升级就对不上——轻则构建失败,重则升级后
15
+ 功能悄悄丢失。上游修过的 bug,会在你本地"复活"。
16
+ - 想改功能、加需求?给上游提 issue,等新版本下发后运行:
17
+
18
+ ```
19
+ ojm init --yes
20
+ ```
21
+
22
+ 它会把这两个目录恢复成当前版本的官方内容。注意:**会丢弃你在里面的本地修改**,
23
+ 执行前先备份。你自己的模块和 config.yaml 等工程配置不受影响。
24
+ - 真的不想要某个自带能力?在 `web.config.ts` 里把对应条目写 `enabled: false`,
25
+ 不要删目录。
26
+ - 删除 `src/.ojm-builtin.json` 会整个关掉上面的检查——不建议,出了问题没人替你兜底。
27
+
28
+ ## 你自己的模块(随便改)
29
+
30
+ - `src/<你的模块名>/` —— 用 `ojm new <名字>` 生成,或手工建 `src/<名字>/{api,web}`。
31
+
32
+ 这里是你的地盘:框架不检查、也不会覆盖。
33
+
34
+ ## 一句话总结
35
+
36
+ > `src/app` 和 `src/oj-market` 是"公家"的,`src/` 下其他目录是你自己的。
37
+ > 公家的不许动(动了 `ojm build` 会拦);要动找上游;升级后跑 `ojm init --yes` 同步。
@@ -302,11 +302,15 @@ export function declOf(req: InstallRequest): SolutionDecl {
302
302
  title_key: typeof r.title_key === "string" ? r.title_key : undefined,
303
303
  permissions: Array.isArray(r.permissions) ? r.permissions.map(String) : [],
304
304
  })),
305
- // 顶层 permissions[] 的 code:无角色权限点(系统预置点)唯一能落库的来源,别在此丢弃
305
+ // 顶层 permissions[] 透传 code + title_key(R2 展示锚随落库走,别在校验后丢弃):
306
+ // 无角色权限点(系统预置点)唯一能落库的来源
306
307
  permissions: (Array.isArray(m.permissions) ? m.permissions : [])
307
308
  .filter(isObj)
308
- .map(p => (typeof p.code === "string" ? p.code : ""))
309
- .filter(Boolean),
309
+ .map(p => ({
310
+ code: typeof p.code === "string" ? p.code : "",
311
+ title_key: typeof p.title_key === "string" ? p.title_key : undefined,
312
+ }))
313
+ .filter(p => p.code !== ""),
310
314
  settings,
311
315
  routes: (Array.isArray(m.routes) ? m.routes : []).filter(isObj).map(r => ({
312
316
  path: typeof r.path === "string" ? r.path : "",
@@ -15,16 +15,22 @@ export interface SolutionRoleDecl {
15
15
  permissions?: string[]
16
16
  }
17
17
 
18
+ export interface SolutionPermDecl {
19
+ code: string
20
+ /** 原始 i18n key(R2 展示锚:C2-15 必填校验的落地处,缺省 null = 展示回退裸 code) */
21
+ title_key?: string
22
+ }
23
+
18
24
  export interface SolutionManifest {
19
25
  module: string
20
26
  roles: SolutionRoleDecl[]
21
27
  /**
22
- * 顶层 `permissions[]` 的 code 清单(契约 C2-15 的声明面)。
28
+ * 顶层 `permissions[]` 声明(契约 C2-15;code + title_key)。
23
29
  * 与 `roles[].permissions` **不是**上下级关系:C2-16 只要求角色引用的 code 先在此声明,
24
30
  * 反过来「声明了但没有任何角色引用」是合法形态(系统预置权限点,由管理员在管理面自行分配)。
25
31
  * 漏收集的后果是这类权限点根本不落库 → 管理面绑不上 → 声明形同虚设。
26
32
  */
27
- permissions?: string[]
33
+ permissions?: SolutionPermDecl[]
28
34
  }
29
35
 
30
36
  export interface UpsertResult {
@@ -35,15 +41,21 @@ export interface UpsertResult {
35
41
  export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertResult> {
36
42
  const deprecated: string[] = [];
37
43
  const declaredRoles = new Set(m.roles.map(r => r.name));
38
- // 权限点全集 = 角色引用的 ∪ 顶层独立声明的(后者是无角色权限点唯一能落库的来源)
39
- const declaredPerms = new Set([...m.roles.flatMap(r => r.permissions ?? []), ...(m.permissions ?? [])]);
44
+ // 权限点全集 = 角色引用的 ∪ 顶层独立声明的(后者是无角色权限点唯一能落库的来源);
45
+ // title_key 是 R2 展示锚,随 code 一起落库
46
+ const declaredPerms = new Map<string, string | undefined>([
47
+ // 角色引用的裸 code 先入(无 title_key),顶层声明(C2-15 携 title_key)后入覆盖——后者权威
48
+ ...m.roles.flatMap(r => r.permissions ?? []).map(c => [c, undefined] as const),
49
+ ...m.permissions?.map(p => [p.code, p.title_key] as const) ?? [],
50
+ ]);
40
51
 
41
52
  for (const role of m.roles) {
42
53
  const existing = await db.table("sys_role").select(["name"]).where({ field: "name", op: "eq", value: role.name }).all() as unknown[];
43
- // upsert:行存在只覆盖元数据(title/title_key——R3 升级即重解析:新包 locale 改进随升级传播),
54
+ // upsert:行存在只覆盖元数据(title/title_key——R3 升级即重解析:新包 locale 改进随升级传播;
55
+ // deprecated 复位 = 重声明回到声明态,对齐 settings/routes 注册面纪律),
44
56
  // name/builtin 不动(AC2-2 元数据覆盖)
45
57
  if (existing.length) {
46
- await db.table("sys_role").update({ title: role.title, title_key: role.title_key ?? null }).where({ field: "name", op: "eq", value: role.name }).run();
58
+ await db.table("sys_role").update({ title: role.title, title_key: role.title_key ?? null, deprecated: 0 }).where({ field: "name", op: "eq", value: role.name }).run();
47
59
  }
48
60
  else {
49
61
  await db.table("sys_role").insert({ name: role.name, module: m.module, title: role.title, title_key: role.title_key ?? null, builtin: 0 }).run();
@@ -58,12 +70,15 @@ export async function upsertSolutionPerms(m: SolutionManifest): Promise<UpsertRe
58
70
  }
59
71
  }
60
72
 
61
- // 权限点行独立于角色创建:声明里的新 code 一律 upsert 插入(AC2-1「新增声明 → upsert 插入新行」——
62
- // 含既有角色新增权限点的场景;绑定是否自动挂上由上面的角色级规则决定)
63
- for (const code of declaredPerms) {
73
+ // 权限点行独立于角色创建:声明里的新 code 一律 upsert(AC2-1「新增声明 → 插入新行」——
74
+ // 含既有角色新增权限点的场景;绑定是否自动挂上由上面的角色级规则决定)。
75
+ // 既有行覆写 title_key(R3)并复位 deprecated(重声明回到声明态);code 是行身份,不动。
76
+ for (const [code, titleKey] of declaredPerms) {
64
77
  const p = await db.table("sys_permission").select(["code"]).where({ field: "code", op: "eq", value: code }).all() as unknown[];
65
78
  if (!p.length)
66
- await db.table("sys_permission").insert({ code, module: m.module }).run();
79
+ await db.table("sys_permission").insert({ code, module: m.module, title_key: titleKey ?? null }).run();
80
+ else
81
+ await db.table("sys_permission").update({ title_key: titleKey ?? null, deprecated: 0 }).where({ field: "code", op: "eq", value: code }).run();
67
82
  }
68
83
 
69
84
  // 声明消失 → 行保留标 deprecated(AC2-3/4):行与绑定全留,判决不突变;
@@ -8,6 +8,7 @@
8
8
  /* eslint-disable test/prefer-lowercase-title -- 任务/AC 编号是验收锚点,保留大写(DB/P1-5) */
9
9
  import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
10
10
 
11
+ import { declOf } from "../../src/app/api/_platform/_lifecycle/installer";
11
12
  import { checkOccupancy, queryAllRegistered, queryRegistered, registerSolution } from "../../src/app/api/_platform/_lifecycle/steps/register";
12
13
  import { installDb } from "../fixture";
13
14
 
@@ -192,3 +193,24 @@ describe("审计:auditOp 落 solution.register", () => {
192
193
  expect(JSON.parse(String(r.detail))).toEqual({ routes: 2, settings: 2, deprecated: [] });
193
194
  });
194
195
  });
196
+
197
+ describe("C2 manifest → SolutionDecl 映射(declOf,installer 侧纯函数)", () => {
198
+ it("permissions[].title_key 透传为对象数组(R2 展示锚:C2-15 收藏的字段不得在数据面丢弃)", () => {
199
+ const decl = declOf({
200
+ name: "wiki",
201
+ version: "1.0.0",
202
+ manifest: {
203
+ permissions: [
204
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
205
+ { code: "wiki.doc.export", title_key: "wiki:perm.doc_export" },
206
+ ],
207
+ roles: [{ name: "wiki.reader", title_key: "wiki:role.reader", permissions: ["wiki.doc.read"] }],
208
+ },
209
+ } as any);
210
+ expect(decl.permissions).toEqual([
211
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
212
+ { code: "wiki.doc.export", title_key: "wiki:perm.doc_export" },
213
+ ]);
214
+ expect(decl.roles[0].title_key).toBe("wiki:role.reader");
215
+ });
216
+ });
@@ -51,8 +51,8 @@ function pmDecl(title: string) {
51
51
  return {
52
52
  module: "pm",
53
53
  version: "1.0.0",
54
- roles: [{ name: "pm.sales", title, title_key: title, permissions: ["pm.doc.create"] as string[] }],
55
- permissions: ["pm.doc.create"],
54
+ roles: [{ name: "pm.sales", title, title_key: title, permissions: ["pm.doc.create"] }],
55
+ permissions: [{ code: "pm.doc.create" }],
56
56
  };
57
57
  }
58
58
 
@@ -132,6 +132,51 @@ describe("AC2-3/4:声明消失与重命名 → deprecated 保留(判决不
132
132
  });
133
133
  });
134
134
 
135
+ describe("R2/R3:权限点 title_key 落库(多语言展示锚)与重声明复位", () => {
136
+ it("R2 title_key 随声明落库:permissions[{code,title_key}] → sys_permission.title_key(缺省→null)", async () => {
137
+ const r = await upsertSolutionPerms({
138
+ module: "wiki",
139
+ roles: [{ name: "wiki.reader", title: "wiki:role.reader", title_key: "wiki:role.reader", permissions: ["wiki.doc.read"] }],
140
+ permissions: [
141
+ { code: "wiki.doc.read", title_key: "wiki:perm.doc_read" },
142
+ { code: "wiki.doc.export" }, // 无角色引用的独立声明点;title_key 缺省 → null(宽松态合法)
143
+ ],
144
+ });
145
+ expect(r.deprecated).toEqual([]);
146
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.read'")[0].title_key).toBe("wiki:perm.doc_read");
147
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.export'")[0].title_key).toBeNull();
148
+ // 角色侧同口径(既有行为再钉一次,防回归)
149
+ expect(rows("SELECT title_key FROM sys_role WHERE name = 'wiki.reader'")[0].title_key).toBe("wiki:role.reader");
150
+ });
151
+
152
+ it("R3 既有权限点行 title_key 元数据随升级覆盖(新包 locale 改进随升级传播)", async () => {
153
+ await upsertSolutionPerms({ module: "wiki", roles: [], permissions: [{ code: "wiki.doc.read", title_key: "wiki:perm.old" }] });
154
+ await upsertSolutionPerms({ module: "wiki", roles: [], permissions: [{ code: "wiki.doc.read", title_key: "wiki:perm.new" }] });
155
+ expect(rows("SELECT title_key FROM sys_permission WHERE code = 'wiki.doc.read'")[0].title_key).toBe("wiki:perm.new");
156
+ });
157
+
158
+ it("AC2-3 重声明复位 deprecated=0:权限点与角色行回归声明态(对齐 settings/routes 注册面同纪律)", async () => {
159
+ await installWikiV019();
160
+ await upsertSolutionPerms({
161
+ module: "wiki",
162
+ roles: [{ name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create"] }], // space.create 与 editor 消失
163
+ });
164
+ expect(Number(rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.space.create'")[0].deprecated)).toBe(1);
165
+ expect(Number(rows("SELECT deprecated FROM sys_role WHERE name = 'wiki.editor'")[0].deprecated)).toBe(1);
166
+ // 下一版本把两者重新声明回来 → deprecated 必须复位,装机告警消停
167
+ const r = await upsertSolutionPerms({
168
+ module: "wiki",
169
+ roles: [
170
+ { name: "wiki.admin", title: "管理员", permissions: ["wiki.doc.create", "wiki.space.create"] },
171
+ { name: "wiki.editor", title: "编辑", permissions: ["wiki.doc.create"] },
172
+ ],
173
+ });
174
+ expect(r.deprecated).toEqual([]);
175
+ expect(Number(rows("SELECT deprecated FROM sys_permission WHERE code = 'wiki.space.create'")[0].deprecated)).toBe(0);
176
+ expect(Number(rows("SELECT deprecated FROM sys_role WHERE name = 'wiki.editor'")[0].deprecated)).toBe(0);
177
+ });
178
+ });
179
+
135
180
  describe("AC2-6:卸载级联清理(唯一减法出口)", () => {
136
181
  it("双维度删除:wiki 角色及其绑定 + 属 wiki 的权限点绑定(含 custom 角色上的);custom 角色本体保留", async () => {
137
182
  await installWikiV019();
@@ -177,7 +222,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
177
222
  await upsertSolutionPerms({
178
223
  module: "oj-registry",
179
224
  roles: [],
180
- permissions: ["oj-registry.publish", "oj-registry.admin"],
225
+ permissions: [{ code: "oj-registry.publish" }, { code: "oj-registry.admin", title_key: "oj-registry:perm.admin" }],
181
226
  });
182
227
 
183
228
  expect(rows("SELECT code, module FROM sys_permission ORDER BY code")).toEqual([
@@ -187,7 +232,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
187
232
  });
188
233
 
189
234
  it("AC2-6 卸载后无角色的权限点一并摘干净(含 custom 角色上的绑定)", async () => {
190
- await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: ["oj-registry.publish"] });
235
+ await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: [{ code: "oj-registry.publish" }] });
191
236
  dbo.exec("INSERT INTO sys_role (name, module, title) VALUES ('custom.dev', 'custom', '开发者')");
192
237
  dbo.exec("INSERT INTO sys_role_permission (role_name, permission_code) VALUES ('custom.dev', 'oj-registry.publish')");
193
238
 
@@ -202,7 +247,7 @@ describe("顶层 permissions[]:无角色的权限点也要落库", () => {
202
247
  it("G7 攻击者视角:声明权限点不等于拿到权限——落库不产生任何绑定", async () => {
203
248
  // 使坏路径:以为「把 code 写进 manifest 就自动获权」。落库的只是权限点标签行,
204
249
  // 判定仍要角色绑定;没有绑定,decideAccess 对该 code 恒 false。
205
- await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: ["oj-registry.publish"] });
250
+ await upsertSolutionPerms({ module: "oj-registry", roles: [], permissions: [{ code: "oj-registry.publish" }] });
206
251
 
207
252
  expect(rows("SELECT * FROM sys_role_permission WHERE permission_code = 'oj-registry.publish'").length).toBe(0);
208
253
  });
@@ -20,7 +20,9 @@ const allModules = [
20
20
  { name: "app", entry: "src/app/web/entry.ts" },
21
21
  // 解决方案市场:第一方能力(消费 _platform 编排面 /api/_platform/market/*,
22
22
  // R29:第三方方案不得仿照此调用面),默认随脚手架下发。保持独立模块——
23
- // 可选能力,删目录即可下线;发布管理页(/publish)也在这个模块里。
23
+ // 可选能力,不想要就改 `enabled: false`(不要删目录:src/app、src/oj-market
24
+ // 受完整性门禁保护,删改会让 ojm build 失败,见 src/README.md);
25
+ // 发布管理页(/publish)也在这个模块里。
24
26
  { name: "oj-market", entry: "src/oj-market/web/entry.ts" },
25
27
  // #region demo-only
26
28
  // 以下为模板内置的演示模块(about/access/exception/outside/route-nest),
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.67",
12
- "@oj-module/runtime/contract": "0.2.67",
13
- "@oj-module/runtime/contract/errors": "0.2.67",
11
+ "@oj-module/runtime": "0.2.68",
12
+ "@oj-module/runtime/contract": "0.2.68",
13
+ "@oj-module/runtime/contract/errors": "0.2.68",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",