@oj-module/cli 0.2.6 → 0.2.14

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 (169) hide show
  1. package/README.md +1 -1
  2. package/devkit/README.md +70 -24
  3. package/devkit/framework-dev.md +14 -5
  4. package/devkit/manual.md +28 -23
  5. package/devkit/module-dev-and-cdn-playground.md +197 -0
  6. package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +130 -45
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +64 -0
  12. package/src/__tests__/versions.test.ts +26 -0
  13. package/src/build.ts +9 -0
  14. package/src/bundle.ts +8 -5
  15. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  16. package/src/contract/__tests__/market-client.integration.test.ts +170 -0
  17. package/src/contract/handler-scan.ts +35 -4
  18. package/src/dev.ts +10 -2
  19. package/src/index.ts +9 -3
  20. package/src/init.ts +24 -0
  21. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +177 -0
  47. package/src/preview.ts +106 -56
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +22 -2
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +24 -2
  54. package/templates/api/src/_platform/README.md +31 -0
  55. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  56. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  57. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  58. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  59. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  60. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  61. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  62. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  63. package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
  64. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  65. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
  66. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  67. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  68. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
  69. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  70. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  71. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  72. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
  73. package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
  74. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
  75. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  76. package/templates/api/src/_platform/contacts/api.ts +2 -2
  77. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  78. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  79. package/templates/api/src/_platform/manifest.yaml +4 -1
  80. package/templates/api/src/_platform/market/api.ts +54 -16
  81. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  82. package/templates/api/src/_platform/schema.yaml +26 -0
  83. package/templates/api/src/_platform/solutions/api.ts +13 -12
  84. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  85. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  86. package/templates/api/src/auth/_shared/session.ts +26 -22
  87. package/templates/api/src/auth/api.ts +1 -1
  88. package/templates/api/src/demo/todos/api.ts +8 -8
  89. package/templates/api/src/home/line/api.ts +4 -4
  90. package/templates/api/src/home/pie/api.ts +4 -4
  91. package/templates/api/src/notification/_shared/notify.ts +97 -57
  92. package/templates/api/src/notification/api.ts +1 -1
  93. package/templates/api/src/notification/manifest.yaml +1 -0
  94. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  95. package/templates/api/src/notification/schema.yaml +7 -0
  96. package/templates/api/src/notification/seed.sql +5 -4
  97. package/templates/api/src/personal-center/profile/api.ts +63 -0
  98. package/templates/api/src/personal-center/upload/api.ts +1 -1
  99. package/templates/api/src/system/login-methods/api.ts +1 -1
  100. package/templates/api/src/system/oplog/api.ts +3 -3
  101. package/templates/api/src/system/permissions/api.ts +2 -2
  102. package/templates/api/src/system/roles/api.ts +2 -2
  103. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  104. package/templates/api/src/system/settings/api.ts +2 -2
  105. package/templates/api/src/system/users/api.ts +2 -2
  106. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  107. package/templates/api/src/system/users/password/api.ts +2 -2
  108. package/templates/api/src/system/users/roles/api.ts +2 -2
  109. package/templates/api/src/web/user-info/api.ts +4 -5
  110. package/templates/api/tests/auth-session.spec.ts +1 -1
  111. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  112. package/templates/api/tests/fixture.ts +8 -16
  113. package/templates/api/tests/guard.spec.ts +1 -1
  114. package/templates/api/tests/l1-builder.test.ts +91 -0
  115. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  116. package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
  117. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  118. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  119. package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
  120. package/templates/api/tests/query-utils.spec.ts +85 -1
  121. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  122. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  123. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  124. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  125. package/templates/api/tests/rbac-settings.spec.ts +66 -21
  126. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  127. package/templates/api/tests/rbac-users.spec.ts +1 -1
  128. package/templates/api/tsconfig.json +8 -0
  129. package/templates/cdn/README.md +48 -0
  130. package/templates/env.d.ts +2 -0
  131. package/templates/global.d.ts +850 -0
  132. package/templates/init.include.yaml +14 -2
  133. package/templates/logs/task-events.jsonl +0 -0
  134. package/templates/openapi.json +580 -23
  135. package/templates/package.json +2 -1
  136. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  137. package/templates/web/src/_platform/__client/api.ts +152 -39
  138. package/templates/web/src/notification/entry.ts +46 -9
  139. package/templates/web/src/notification/locales/en-US.json +19 -0
  140. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  141. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  142. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  143. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  144. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  145. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  146. package/templates/web/src/oj-market/api/client.ts +5 -105
  147. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  148. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  149. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  150. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  151. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  152. package/templates/web/src/oj-market/entry.ts +8 -2
  153. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  154. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  155. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  156. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  157. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  158. package/templates/web/src/personal-center/__client/api.ts +25 -1
  159. package/templates/web/src/personal-center/entry.ts +5 -0
  160. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  161. package/templates/web/src/system/entry.ts +2 -1
  162. package/templates/web.config.ts +13 -10
  163. package/vendor/host-versions.json +3 -3
  164. package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
  165. package/templates/api/src/notifications/api.ts +0 -32
  166. package/templates/api/src/notifications/manifest.yaml +0 -5
  167. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  168. package/templates/web/src/notifications/__client/api.ts +0 -80
  169. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
@@ -51,6 +51,7 @@ interface Row {
51
51
  interface NotifyDb {
52
52
  query: (sql: string, params?: unknown[]) => Promise<Row[]>
53
53
  exec: (sql: string, params?: unknown[]) => Promise<number>
54
+ table: DBInstance["table"]
54
55
  }
55
56
  declare const db: NotifyDb;
56
57
 
@@ -68,12 +69,17 @@ function splitId(id: string): [scope: "u" | "b", raw: string] {
68
69
  function mapItem(r: Row): NoticeItem {
69
70
  const scope = String(r.scope);
70
71
  const isBroadcast = scope === "broadcast";
72
+ // UNION 结果列名取首分支(userQ)=> 位置 4 名为 is_read。按人项此处即存储整数列;
73
+ // 广播项此处实际承载回执 read_time(非空即已读,与原始 SQL 的 (read_time IS NOT NULL AND <> '') 等价)。
74
+ const isRead = isBroadcast
75
+ ? (r.is_read != null && String(r.is_read) !== "")
76
+ : Number(r.is_read) === 1;
71
77
  return {
72
78
  id: (isBroadcast ? "b:" : "u:") + String(r.id),
73
79
  scope: isBroadcast ? "broadcast" : "user",
74
80
  avatar: String(r.avatar ?? ""),
75
81
  date: String(r.date ?? ""),
76
- isRead: Number(r.is_read) === 1,
82
+ isRead,
77
83
  message: String(r.message ?? ""),
78
84
  title: String(r.title ?? ""),
79
85
  link: r.link == null ? undefined : String(r.link),
@@ -83,58 +89,53 @@ function mapItem(r: Row): NoticeItem {
83
89
 
84
90
  /** 给指定用户推一条按人通知,返回命名空间 id "u:<id>" */
85
91
  export async function pushNotice(input: PushInput): Promise<string> {
86
- const ins = await db.query(
87
- `INSERT INTO notifications (user_id, avatar, date, is_read, message, title, link, category)
88
- VALUES (?, ?, ?, 0, ?, ?, ?, ?) RETURNING id`,
89
- [
90
- Number(input.userId),
91
- input.avatar ?? "",
92
- nowStr(),
93
- input.message,
94
- input.title,
95
- input.link ?? null,
96
- input.category ?? "notice",
97
- ],
98
- );
92
+ const ins = await db.table("notifications").insert({
93
+ user_id: Number(input.userId),
94
+ avatar: input.avatar ?? "",
95
+ date: nowStr(),
96
+ is_read: 0,
97
+ message: input.message,
98
+ title: input.title,
99
+ link: input.link ?? null,
100
+ category: input.category ?? "notice",
101
+ }).returning(["id"]).run() as Array<{ id: number }>;
99
102
  return `u:${Number(ins[0]!.id)}`;
100
103
  }
101
104
 
102
105
  /** 广播一条通知(全员可见,一行存储),返回命名空间 id "b:<id>" */
103
106
  export async function pushBroadcastNotice(input: PushBroadcastInput): Promise<string> {
104
- const ins = await db.query(
105
- `INSERT INTO notifications_broadcast (avatar, date, message, title, link, category, create_time)
106
- VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
107
- [
108
- input.avatar ?? "",
109
- nowStr(),
110
- input.message,
111
- input.title,
112
- input.link ?? null,
113
- input.category ?? "notice",
114
- nowStr(),
115
- ],
116
- );
107
+ const ins = await db.table("notifications_broadcast").insert({
108
+ avatar: input.avatar ?? "",
109
+ date: nowStr(),
110
+ message: input.message,
111
+ title: input.title,
112
+ link: input.link ?? null,
113
+ category: input.category ?? "notice",
114
+ create_time: nowStr(),
115
+ }).returning(["id"]).run() as Array<{ id: number }>;
117
116
  return `b:${Number(ins[0]!.id)}`;
118
117
  }
119
118
 
120
119
  /**
121
- * 删除通知。按人项校验归属:非本人返回 false;广播项仅删本人回执(幂等,返回 true)。
120
+ * 删除通知。按人项校验归属:非本人返回 false;广播项是共享行,物理删除会影响
121
+ * 其他收件人——落「对我隐藏」标记(幂等,返回 true),列表查询过滤 hidden 命中项。
122
122
  * 返回是否成功操作到目标(调用方据 false 判 403)。
123
123
  */
124
124
  export async function delNotice(id: string, userId: string | number): Promise<boolean> {
125
125
  const [scope, raw] = splitId(id);
126
126
  const uid = Number(userId);
127
127
  if (scope === "b") {
128
- await db.exec(
129
- "DELETE FROM notifications_broadcast_read WHERE broadcast_id = ? AND user_id = ?",
130
- [Number(raw), uid],
131
- );
128
+ await db.table("notifications_broadcast_hidden")
129
+ .insert({ broadcast_id: Number(raw), user_id: uid, create_time: nowStr() })
130
+ .orIgnore()
131
+ .run();
132
132
  return true;
133
133
  }
134
- const n = await db.exec(
135
- "DELETE FROM notifications WHERE id = ? AND user_id = ?",
136
- [Number(raw), uid],
137
- );
134
+ const n = await db.table("notifications")
135
+ .where({ field: "id", op: "eq", value: Number(raw) })
136
+ .where({ field: "user_id", op: "eq", value: uid })
137
+ .delete()
138
+ .run() as number;
138
139
  return n > 0;
139
140
  }
140
141
 
@@ -146,18 +147,43 @@ export async function noticesFor(
146
147
  const limit = opts.limit ?? 50;
147
148
  const offset = opts.offset ?? 0;
148
149
  const uid = Number(userId);
149
- const rows = await db.query(
150
- `SELECT id, avatar, date, is_read, message, title, link, category, 'user' as scope
151
- FROM notifications WHERE user_id = ?
152
- UNION ALL
153
- SELECT b.id, b.avatar, b.date, (r.read_time IS NOT NULL AND r.read_time <> '') as is_read,
154
- b.message, b.title, b.link, b.category, 'broadcast' as scope
155
- FROM notifications_broadcast b
156
- LEFT JOIN notifications_broadcast_read r ON r.broadcast_id = b.id AND r.user_id = ?
157
- ORDER BY date DESC
158
- LIMIT ? OFFSET ?`,
159
- [uid, uid, limit, offset],
160
- );
150
+ // 构造器(v0.1.81):UNION ALL 合并「按人」与「广播(LEFT JOIN 回执)」;lit 打固定 scope 标记列,
151
+ // 顶层 orderBy/limit/offset 跨方言一致。
152
+ // 注意:UNION 结果列名取首分支(userQ),故两分支位置对齐即可——广播第 4 列取回执 read_time,
153
+ // 经 union 落到 is_read 列名下,是否「已读」由 mapItem 据非空推导(与原始 SQL 同源)。
154
+ const userQ = db.table("notifications").select([
155
+ "id",
156
+ "avatar",
157
+ "date",
158
+ "is_read",
159
+ "message",
160
+ "title",
161
+ "link",
162
+ "category",
163
+ { lit: "user", as: "scope" },
164
+ ]).where({ field: "user_id", op: "eq", value: uid });
165
+ const broadcastQ = db.table("notifications_broadcast").as("b").join("notifications_broadcast_read", [
166
+ { left: "b.id", right: "r.broadcast_id" },
167
+ { left: "r.user_id", op: "eq", value: uid },
168
+ ], "left", "r").join("notifications_broadcast_hidden", [
169
+ { left: "b.id", right: "h.broadcast_id" },
170
+ { left: "h.user_id", op: "eq", value: uid },
171
+ ], "left", "h").where({ field: "h.id", op: "isnull" }).select([
172
+ "b.id",
173
+ "b.avatar",
174
+ "b.date",
175
+ "r.read_time",
176
+ "b.message",
177
+ "b.title",
178
+ "b.link",
179
+ "b.category",
180
+ { lit: "broadcast", as: "scope" },
181
+ ]);
182
+ const rows = await userQ.union(broadcastQ, "all")
183
+ .orderBy([{ field: "date", dir: "desc" }])
184
+ .limit(limit)
185
+ .offset(offset)
186
+ .all();
161
187
  return rows.map(mapItem);
162
188
  }
163
189
 
@@ -170,13 +196,18 @@ export async function markRead(id: string, userId: string | number): Promise<boo
170
196
  const uid = Number(userId);
171
197
  const now = nowStr();
172
198
  if (scope === "b") {
173
- await db.exec(
174
- "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) VALUES (?, ?, ?)",
175
- [Number(raw), uid, now],
176
- );
199
+ // 构造器(v0.1.81):orIgnore 幂等写入(sqlite/pg → ON CONFLICT DO NOTHING;mysql → ON DUPLICATE KEY UPDATE pk=pk)。
200
+ await db.table("notifications_broadcast_read")
201
+ .insert({ broadcast_id: Number(raw), user_id: uid, read_time: now })
202
+ .orIgnore()
203
+ .run();
177
204
  return true;
178
205
  }
179
- const n = await db.exec("UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?", [Number(raw), uid]);
206
+ const n = await db.table("notifications")
207
+ .where({ field: "id", op: "eq", value: Number(raw) })
208
+ .where({ field: "user_id", op: "eq", value: uid })
209
+ .update({ is_read: 1 })
210
+ .run() as number;
180
211
  return n > 0;
181
212
  }
182
213
 
@@ -184,16 +215,25 @@ export async function markRead(id: string, userId: string | number): Promise<boo
184
215
  export async function markAllRead(userId: string | number): Promise<void> {
185
216
  const uid = Number(userId);
186
217
  const now = nowStr();
187
- await db.exec("UPDATE notifications SET is_read = 1 WHERE user_id = ?", [uid]);
218
+ await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).update({ is_read: 1 }).run();
219
+ // 保留 db.exec:INSERT OR IGNORE ... SELECT 在 sqlite 上构造器无法表达——
220
+ // oj 0.1.81 的 orIgnore 仅跟随 VALUES 的 upsert 子句,不与 fromSelect(SELECT 源) 组合
221
+ // (实测:code 500 "orIgnore with fromSelect is not supported on sqlite")。原 SQL 三方言通用
222
+ // (sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql ON DUPLICATE KEY),退回裸 SQL。
188
223
  await db.exec(
189
224
  "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
190
225
  [uid, now],
191
226
  );
192
227
  }
193
228
 
194
- /** 清空:删按人项 + 删本人广播回执(列表消失) */
229
+ /** 清空:删按人项 + 广播逐条落「对我隐藏」标记(共享行不可物理删,他人不受影响) */
195
230
  export async function clearNotices(userId: string | number): Promise<void> {
196
231
  const uid = Number(userId);
197
- await db.exec("DELETE FROM notifications WHERE user_id = ?", [uid]);
198
- await db.exec("DELETE FROM notifications_broadcast_read WHERE user_id = ?", [uid]);
232
+ await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).delete().run();
233
+ // 保留 db.exec:INSERT OR IGNORE ... SELECT 同 markAllRead(构造器 orIgnore 不与
234
+ // fromSelect 组合,实测 500),原 SQL 三方言通用。
235
+ 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()],
238
+ );
199
239
  }
@@ -72,7 +72,7 @@ const notifications = {
72
72
  },
73
73
  },
74
74
  // POST /api/notification/notifications/clear —— 清空通知(按当前用户)。
75
- // 删按人项 + 删本人广播回执(列表消失)。
75
+ // 删按人项 + 广播逐条落「对我隐藏」标记(共享行不可物理删,他人不受影响)。
76
76
  "clear": {
77
77
  async post() {
78
78
  const uid = http.user?.id;
@@ -5,3 +5,4 @@ tables:
5
5
  - notifications
6
6
  - notifications_broadcast
7
7
  - notifications_broadcast_read
8
+ - notifications_broadcast_hidden
@@ -0,0 +1,12 @@
1
+ -- 广播隐藏标记表(按人):清空/单条删除广播项时落标记——广播本体是共享行,
2
+ -- 任何人不得物理删除(会影响其他收件人),只能「对我隐藏」。
3
+ -- 清空 = 按人项物理删 + 广播逐条落 hidden 标记;列表查询过滤 hidden 命中项。
4
+ -- 三方言可移植:建表 + 索引均为标准 DDL。
5
+ CREATE TABLE IF NOT EXISTS notifications_broadcast_hidden (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ broadcast_id INTEGER NOT NULL,
8
+ user_id INTEGER NOT NULL,
9
+ create_time TEXT NOT NULL DEFAULT ''
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS idx_broadcast_hidden ON notifications_broadcast_hidden (user_id, broadcast_id);
@@ -31,3 +31,10 @@ tables:
31
31
  broadcast_id: {type: integer, null: false}
32
32
  user_id: {type: integer, null: false}
33
33
  read_time: {type: text, null: false}
34
+ notifications_broadcast_hidden:
35
+ pk: id
36
+ columns:
37
+ id: {type: integer, autoincrement: true}
38
+ broadcast_id: {type: integer, null: false}
39
+ user_id: {type: integer, null: false}
40
+ create_time: {type: text, null: false}
@@ -1,6 +1,7 @@
1
1
  -- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
2
+ -- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
2
3
  -- 按分号朴素切分(S006):语句内不得含分号字面量。
3
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, '', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / 123456 登录开始', '欢迎', NULL, 'notice');
4
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, '', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
5
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, '', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
6
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, '', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
4
+ INSERT OR 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 / 123456 登录开始', '欢迎', NULL, 'notice');
5
+ INSERT OR 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');
6
+ INSERT OR 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');
7
+ INSERT OR 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,63 @@
1
+ // POST /api/personal-center/profile —— 保存「我的资料」(个人中心表单入库端点)。
2
+ // 只允许改本人行(http.user.id 权威,不接受 body 传 id);username 全局唯一,
3
+ // 重名 400;avatar 走 upload 端点(multipart),不在此通道。
4
+ const handler = {
5
+ async post() {
6
+ const uid = http.user?.id;
7
+ if (!uid) {
8
+ json.fail(401, "unauthorized");
9
+ return;
10
+ }
11
+ const b = (http.body ?? {}) as {
12
+ username?: unknown
13
+ email?: unknown
14
+ phoneNumber?: unknown
15
+ description?: unknown
16
+ };
17
+ const patch: Record<string, string> = {};
18
+ if (b.username != null && String(b.username).trim() !== "") {
19
+ const username = String(b.username).trim();
20
+ const dup = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
21
+ if (dup.some(r => Number(r.id) !== Number(uid))) {
22
+ json.fail(400, "username already taken");
23
+ return;
24
+ }
25
+ patch.username = username;
26
+ }
27
+ if (b.email != null)
28
+ patch.email = String(b.email);
29
+ if (b.phoneNumber != null)
30
+ patch.phone_number = String(b.phoneNumber);
31
+ if (b.description != null)
32
+ patch.description = String(b.description);
33
+ if (Object.keys(patch).length === 0) {
34
+ json.fail(400, "nothing to update");
35
+ return;
36
+ }
37
+ try {
38
+ await db.table("users").update(patch).where({ field: "id", op: "eq", value: Number(uid) }).run();
39
+ json.ok(null);
40
+ }
41
+ catch (e) {
42
+ json.fail(500, String(e));
43
+ }
44
+ },
45
+ };
46
+
47
+ // 入参:四字段均可选(部分保存允许),类型即字符串。
48
+ handler.post.schema = {
49
+ body: {
50
+ type: "object",
51
+ properties: {
52
+ username: { type: "string" },
53
+ email: { type: "string" },
54
+ phoneNumber: { type: "string" },
55
+ description: { type: "string" },
56
+ },
57
+ },
58
+ };
59
+
60
+ // 出参:无业务载荷(前端保存后经 user-info 重拉刷新 store)。
61
+ handler.post.response = { type: "null" };
62
+
63
+ export default handler;
@@ -41,7 +41,7 @@ const handler = {
41
41
  const bytes = meta.key ? await blob.get(meta.key) : await http.file(0);
42
42
  const b64 = toBase64(bytes);
43
43
  const dataUrl = `data:${meta.content_type || "application/octet-stream"};base64,${b64}`;
44
- await db.exec("UPDATE users SET avatar_base64 = ? WHERE id = ?", [dataUrl, Number(uid)]);
44
+ await db.table("users").update({ avatar_base64: dataUrl }).where({ field: "id", op: "eq", value: Number(uid) }).run();
45
45
  // 大文件搬运/留档用 blob(v0.1.47):搬走用 blob.move,别 get + put(字节进 V8)
46
46
  if (meta.key)
47
47
  await blob.del(meta.key);
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
2
  // GET /api/system/login-methods —— 登录方式发现(C5/D2;匿名端点:登录前无 token,
3
3
  // 项目 config.yaml auth.anonymous_paths 必须列 /system/login-methods,漏列则握手 401 登录页必坏)。
4
- import { listLoginMethods } from "../../_platform/_shared/service/settings";
4
+ import { listLoginMethods } from "../../_platform/_shared/service/domain/settings";
5
5
 
6
6
  async function get() {
7
7
  await respond(async () => {
@@ -1,9 +1,9 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
2
  // GET /api/system/oplog?action=&username=&module=&limit= —— 操作日志查询(AC1-29;D13 admin 可查)。
3
3
  // POST /api/system/oplog —— 操作日志写入(C3;登录态即可,解决方案模块记录自身操作)。
4
- import { assertSystemAdmin, currentAccess } from "../../_platform/_shared/service/access";
5
- import { recordOp } from "../../_platform/_shared/service/audit";
6
- import { searchOplog } from "../../_platform/_shared/service/oplog";
4
+ import { assertSystemAdmin, currentAccess } from "../../_platform/_shared/service/infra/access";
5
+ import { recordOp } from "../../_platform/_shared/service/infra/audit";
6
+ import { searchOplog } from "../../_platform/_shared/service/infra/oplog";
7
7
 
8
8
  async function get() {
9
9
  await respond(async () => {
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
+ import { listPermissions } from "../../_platform/_shared/service/domain/roles";
2
3
  // GET /api/system/permissions —— 权限点全量(C2 绑定编辑器数据源,deprecated 标灰)。
3
- import { assertSystemAdmin } from "../../_platform/_shared/service/access";
4
- import { listPermissions } from "../../_platform/_shared/service/roles";
4
+ import { assertSystemAdmin } from "../../_platform/_shared/service/infra/access";
5
5
 
6
6
  async function get() {
7
7
  await respond(async () => {
@@ -1,8 +1,8 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
+ import { createRole, deleteRole, listRoles } from "../../_platform/_shared/service/domain/roles";
2
3
  // 管理域端点 /api/system/roles(设计文档 D207:/api/system/* = 管理域,assertSystemAdmin 判权)。
3
4
  // handler 层只编排:守卫 → service → json 信封;name 走 query(wiki 先例:静态段 + http.query)。
4
- import { assertSystemAdmin } from "../../_platform/_shared/service/access";
5
- import { createRole, deleteRole, listRoles } from "../../_platform/_shared/service/roles";
5
+ import { assertSystemAdmin } from "../../_platform/_shared/service/infra/access";
6
6
 
7
7
  async function guard(): Promise<void> {
8
8
  await assertSystemAdmin();
@@ -1,9 +1,9 @@
1
1
  import { respond } from "../../../_platform/_shared/respond";
2
+ import { updateBindings } from "../../../_platform/_shared/service/domain/roles";
2
3
  // PUT /api/system/roles/bindings?name=xxx —— 整集替换角色绑定。
3
4
  // AC1-18 声明角色绑定可调整;AC1-19 builtin 硬拒(service 层);AC1-30 摘绑定 = 紧急吊销出口
4
5
  // (service 内写后按持有者失效判权缓存)。
5
- import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
6
- import { updateBindings } from "../../../_platform/_shared/service/roles";
6
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/infra/access";
7
7
 
8
8
  async function put() {
9
9
  await respond(async () => {
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
+ import { listSettings, updateSetting } from "../../_platform/_shared/service/domain/settings";
2
3
  // /api/system/settings —— 系统设置(PRD §4.3):GET 按组列出(secret 掩码),PUT 写值(写审计)。
3
- import { assertSystemAdmin } from "../../_platform/_shared/service/access";
4
- import { listSettings, updateSetting } from "../../_platform/_shared/service/settings";
4
+ import { assertSystemAdmin } from "../../_platform/_shared/service/infra/access";
5
5
 
6
6
  const settingItemSchema = {
7
7
  type: "object",
@@ -1,9 +1,9 @@
1
1
  import { respond } from "../../_platform/_shared/respond";
2
+ import { createUser, listUsers } from "../../_platform/_shared/service/domain/users";
2
3
  // 管理域端点组 /api/system/users(AC1-7~10):handler 只编排——assertSystemAdmin 守卫 → service → json。
3
4
  // 保护红线在 service 层硬 enforcement:禁止操作自己、admin 不碰成员资格、最后 superadmin 兜底。
4
5
  // 子端点:roles/password/forbidden 各为独立目录(wiki per-endpoint 先例)。
5
- import { assertSystemAdmin } from "../../_platform/_shared/service/access";
6
- import { createUser, listUsers } from "../../_platform/_shared/service/users";
6
+ import { assertSystemAdmin } from "../../_platform/_shared/service/infra/access";
7
7
 
8
8
  const bcryptDeps = { bcryptHash: (pw: string) => bcrypt.hash(pw) };
9
9
 
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../../_platform/_shared/respond";
2
+ import { setForbidden } from "../../../_platform/_shared/service/domain/users";
2
3
  // PUT /api/system/users/forbidden?uid= —— 禁用/解禁(禁用即删 refresh,撤销即强退)。
3
- import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
- import { setForbidden } from "../../../_platform/_shared/service/users";
4
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/infra/access";
5
5
 
6
6
  async function put() {
7
7
  await respond(async () => {
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../../_platform/_shared/respond";
2
+ import { changePasswordByAdmin } from "../../../_platform/_shared/service/domain/users";
2
3
  // PUT /api/system/users/password?uid= —— 管理员重置口令(must_change=1 + 级联吊销 refresh)。
3
- import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
- import { changePasswordByAdmin } from "../../../_platform/_shared/service/users";
4
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/infra/access";
5
5
 
6
6
  async function put() {
7
7
  await respond(async () => {
@@ -1,7 +1,7 @@
1
1
  import { respond } from "../../../_platform/_shared/respond";
2
+ import { updateUserRoles } from "../../../_platform/_shared/service/domain/users";
2
3
  // PUT /api/system/users/roles?uid= —— 整集替换用户角色(AC1-7;sys_user_role 权威源 + 写时失效)。
3
- import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
- import { updateUserRoles } from "../../../_platform/_shared/service/users";
4
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/infra/access";
5
5
 
6
6
  async function put() {
7
7
  await respond(async () => {
@@ -10,22 +10,21 @@ export default {
10
10
  return;
11
11
  }
12
12
  try {
13
- const rows = await db.query(
14
- "SELECT id, username, display_name, avatar_base64, email, must_change FROM users WHERE id = ?",
15
- [Number(uid)],
16
- );
13
+ const rows = await db.table("users").select(["id", "username", "display_name", "avatar_base64", "email", "phone_number", "description", "must_change"]).where({ field: "id", op: "eq", value: Number(uid) }).all();
17
14
  const row = rows[0];
18
15
  if (!row) {
19
16
  json.fail(404, "user not found");
20
17
  return;
21
18
  }
22
- const roleRows = await db.query("SELECT role_name FROM sys_user_role WHERE user_id = ?", [Number(uid)]) as Array<{ role_name: string }>;
19
+ const roleRows = await db.table("sys_user_role").select(["role_name"]).where({ field: "user_id", op: "eq", value: Number(uid) }).all() as Array<{ role_name: string }>;
23
20
  json.ok({
24
21
  id: String(row.id),
25
22
  avatar: String(row.avatar_base64 ?? ""),
26
23
  username: String(row.username),
27
24
  displayName: row.display_name == null ? "" : String(row.display_name),
28
25
  email: String(row.email ?? ""),
26
+ phoneNumber: row.phone_number == null ? "" : String(row.phone_number),
27
+ description: row.description == null ? "" : String(row.description),
29
28
  roles: roleRows.map(r => r.role_name),
30
29
  mustChange: Number(row.must_change) === 1,
31
30
  permissions: [],
@@ -4,7 +4,7 @@
4
4
  import type { SessionDeps } from "../src/auth/_shared/session";
5
5
 
6
6
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
7
- import { assertPermission } from "../src/_platform/_shared/service/access";
7
+ import { assertPermission } from "../src/_platform/_shared/service/infra/access";
8
8
  import { createSession } from "../src/auth/_shared/session";
9
9
  import { installDb } from "./fixture";
10
10
 
@@ -5,7 +5,7 @@
5
5
  /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
6
6
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
7
7
 
8
- import { loginBlocked, loginFailed, loginSucceeded, resetLoginThrottle } from "../src/_platform/_shared/service/rate";
8
+ import { loginBlocked, loginFailed, loginSucceeded, resetLoginThrottle } from "../src/_platform/_shared/service/infra/rate";
9
9
  import { installDb } from "./fixture";
10
10
 
11
11
  let dbo: ReturnType<typeof installDb>;
@@ -1,6 +1,14 @@
1
1
  // 后端 BDD 夹具:node:sqlite 内存库 + **真实迁移文件**建表,装到 globalThis.db——
2
2
  // 与 oj 运行时注入的全局同名同面(query/exec/tx/dialect),service 代码不改一行即可被测。
3
3
  // 夹具直接读 api/src/<模块>/migrations/*.sql,与迁移不可能漂移(沿用 oj-wiki 先例)。
4
+ //
5
+ // **构造器边界(架构定论,勿在此夹具里补 table())**:db.table()/listTables() 等构造器是
6
+ // oj 二进制内的纯 Rust 实现,无 JS 模块可导出,Node 侧垫不出来——本夹具刻意只给
7
+ // {query, exec, tx, dialect}。两层测试的分工因此定型:
8
+ // · L2(本目录 *.spec.ts):构造器面用**记录型假 builder**(记 where/orderBy/limit 调用
9
+ // 序列做编排断言);真 SQL 执行断言走 db.query 直查。
10
+ // · L1(同目录 *.test.ts,`oj test` 收集):真 v8 + 真构造器闭环,守 SQL 渲染/过滤/转义
11
+ // 的真执行面(样板见 tests/l1-builder.test.ts;方法论见 devkit oj-test-guide.md)。
4
12
  import { createRequire } from "node:module";
5
13
 
6
14
  const req = createRequire(import.meta.url);
@@ -13,22 +21,6 @@ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
13
21
 
14
22
  export const ADMIN = 1;
15
23
 
16
- /**
17
- * market_history 同构 DDL:表真源 = 市场方案包自带迁移(oj-market 0002__market_history),
18
- * 不属平台迁移(installDb 不回放)——需要断言留痕的用例显式执行;守卫语义(表不存在静默跳过)
19
- * 另有专项用例钉住。
20
- */
21
- export const MARKET_HISTORY_DDL = `CREATE TABLE market_history (
22
- id INTEGER PRIMARY KEY AUTOINCREMENT,
23
- solution TEXT NOT NULL,
24
- version TEXT NOT NULL,
25
- action TEXT NOT NULL,
26
- status TEXT NOT NULL,
27
- operator TEXT,
28
- message TEXT,
29
- created_at INTEGER NOT NULL
30
- )`;
31
-
32
24
  /** 建内存库 + 注入全局;返回 DatabaseSync 供用例直插数据/断言。 */
33
25
  export function installDb(): any {
34
26
  const dbo = new DatabaseSync(":memory:");
@@ -3,7 +3,7 @@
3
3
  // (真实迁移建表 / 运行时全局 db 同面 / 事务往返)——S0 spike 的「一条真实判权 AC + 形态证明」。
4
4
  import { beforeAll, describe, expect, it } from "vitest";
5
5
 
6
- import { decideAccess, moduleOf } from "../src/_platform/_shared/service/guard";
6
+ import { decideAccess, moduleOf } from "../src/_platform/_shared/service/infra/guard";
7
7
  import { ADMIN, installDb } from "./fixture";
8
8
 
9
9
  const as = (roles: string[], bindings: Record<string, string[]> = {}) => ({ roles, bindings });