create-windy 0.2.34 → 0.3.0

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 (237) hide show
  1. package/dist/cli.js +167 -17
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/AGENTS.md +5 -0
  5. package/template/README.md +3 -1
  6. package/template/apps/agent-server/Dockerfile +1 -0
  7. package/template/apps/agent-server/README.md +18 -5
  8. package/template/apps/agent-server/adk_web_agents/__init__.py +1 -0
  9. package/template/apps/agent-server/adk_web_agents/safe_debug/__init__.py +3 -0
  10. package/template/apps/agent-server/adk_web_agents/safe_debug/agent.py +40 -0
  11. package/template/apps/agent-server/pyproject.toml +1 -1
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +191 -16
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_failure.py +13 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +225 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +76 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/app.py +1 -2
  17. package/template/apps/agent-server/tests/test_adk_adapter.py +305 -0
  18. package/template/apps/agent-server/tests/test_adk_web_agent.py +12 -0
  19. package/template/apps/server/package.json +4 -1
  20. package/template/apps/server/src/agent/run-facade.ts +2 -7
  21. package/template/apps/server/src/agent/tool-registry.ts +2 -6
  22. package/template/apps/server/src/application-services.ts +50 -0
  23. package/template/apps/server/src/audit/drizzle-log-store.ts +25 -0
  24. package/template/apps/server/src/audit/search-summary.ts +2 -6
  25. package/template/apps/server/src/auth/credential-restriction.ts +1 -0
  26. package/template/apps/server/src/auth/password.ts +30 -21
  27. package/template/apps/server/src/auth/routes.test.ts +44 -3
  28. package/template/apps/server/src/auth/routes.ts +6 -1
  29. package/template/apps/server/src/auth/runtime.ts +4 -1
  30. package/template/apps/server/src/auth/service.ts +25 -7
  31. package/template/apps/server/src/configuration/bootstrap.ts +20 -1
  32. package/template/apps/server/src/configuration/definition.test.ts +71 -0
  33. package/template/apps/server/src/configuration/definition.ts +242 -12
  34. package/template/apps/server/src/configuration/drizzle-repository.ts +36 -2
  35. package/template/apps/server/src/configuration/repository.ts +27 -2
  36. package/template/apps/server/src/configuration/routes.ts +4 -1
  37. package/template/apps/server/src/configuration/service.test.ts +100 -0
  38. package/template/apps/server/src/configuration/service.ts +50 -10
  39. package/template/apps/server/src/data-access/scoped-repository.integration.test.ts +3 -2
  40. package/template/apps/server/src/data-governance/export/service.ts +2 -6
  41. package/template/apps/server/src/example-modules.ts +4 -1
  42. package/template/apps/server/src/guards.ts +18 -5
  43. package/template/apps/server/src/http/request-source.test.ts +85 -0
  44. package/template/apps/server/src/http/request-source.ts +90 -0
  45. package/template/apps/server/src/index.ts +23 -6
  46. package/template/apps/server/src/license/routes.ts +2 -0
  47. package/template/apps/server/src/license/runtime-service.ts +10 -11
  48. package/template/apps/server/src/module-bootstrap.ts +2 -2
  49. package/template/apps/server/src/module-host/request-context.ts +6 -6
  50. package/template/apps/server/src/module-host/storage-scope.ts +2 -9
  51. package/template/apps/server/src/module-host/user-directory-port.ts +2 -6
  52. package/template/apps/server/src/notification/channel-adapter.ts +46 -0
  53. package/template/apps/server/src/notification/delivery-policy.test.ts +59 -0
  54. package/template/apps/server/src/notification/delivery-policy.ts +37 -0
  55. package/template/apps/server/src/notification/delivery-repository.ts +198 -0
  56. package/template/apps/server/src/notification/delivery-worker.test.ts +87 -0
  57. package/template/apps/server/src/notification/delivery-worker.ts +88 -0
  58. package/template/apps/server/src/notification/drizzle-management.ts +180 -0
  59. package/template/apps/server/src/notification/drizzle-query.ts +160 -0
  60. package/template/apps/server/src/notification/drizzle-repository.integration.test.ts +301 -0
  61. package/template/apps/server/src/notification/drizzle-repository.ts +156 -90
  62. package/template/apps/server/src/notification/extension-routes.test.ts +119 -0
  63. package/template/apps/server/src/notification/extension-routes.ts +111 -0
  64. package/template/apps/server/src/notification/memory-delivery-repository.ts +137 -0
  65. package/template/apps/server/src/notification/memory-support.ts +72 -0
  66. package/template/apps/server/src/notification/preference-repository.ts +95 -0
  67. package/template/apps/server/src/notification/recipient-directory.ts +104 -0
  68. package/template/apps/server/src/notification/repository.test.ts +171 -0
  69. package/template/apps/server/src/notification/repository.ts +179 -32
  70. package/template/apps/server/src/notification/revision-routes.test.ts +186 -0
  71. package/template/apps/server/src/notification/route-support.ts +225 -0
  72. package/template/apps/server/src/notification/routes.test.ts +182 -0
  73. package/template/apps/server/src/notification/routes.ts +225 -132
  74. package/template/apps/server/src/notification/service.test.ts +104 -0
  75. package/template/apps/server/src/notification/service.ts +140 -0
  76. package/template/apps/server/src/notification/smtp-adapter.test.ts +65 -0
  77. package/template/apps/server/src/notification/smtp-adapter.ts +68 -0
  78. package/template/apps/server/src/persistence.integration.test.ts +41 -2
  79. package/template/apps/server/src/route-guards.ts +1 -0
  80. package/template/apps/server/src/runtime-audit-list.test.ts +39 -0
  81. package/template/apps/server/src/runtime-development.ts +1 -0
  82. package/template/apps/server/src/runtime.test.ts +23 -9
  83. package/template/apps/server/src/runtime.ts +39 -2
  84. package/template/apps/server/src/scheduler/audit.ts +2 -11
  85. package/template/apps/server/src/scheduler/definitions.test.ts +6 -0
  86. package/template/apps/server/src/scheduler/definitions.ts +7 -0
  87. package/template/apps/server/src/scheduler/recovery-service.ts +2 -11
  88. package/template/apps/server/src/scheduler/routes.ts +2 -7
  89. package/template/apps/server/src/search/opensearch-client.test.ts +45 -0
  90. package/template/apps/server/src/search/opensearch-client.ts +145 -0
  91. package/template/apps/server/src/search/service.ts +2 -6
  92. package/template/apps/server/src/settings/routes.ts +15 -3
  93. package/template/apps/server/src/settings/runtime.ts +10 -1
  94. package/template/apps/server/src/storage/runtime.ts +13 -0
  95. package/template/apps/server/src/system/audit-query.ts +33 -4
  96. package/template/apps/server/src/system/audit-routes.test.ts +58 -4
  97. package/template/apps/server/src/system/audit-routes.ts +6 -5
  98. package/template/apps/server/src/system/department-organization.ts +320 -0
  99. package/template/apps/server/src/system/department-routes.test.ts +207 -48
  100. package/template/apps/server/src/system/department-routes.ts +114 -33
  101. package/template/apps/server/src/system/drizzle-repository.ts +15 -7
  102. package/template/apps/server/src/system/drizzle-scoped-repository.ts +5 -3
  103. package/template/apps/server/src/system/drizzle-system.ts +37 -9
  104. package/template/apps/server/src/system/drizzle-transaction-context.ts +14 -0
  105. package/template/apps/server/src/system/identity-route-config.ts +7 -2
  106. package/template/apps/server/src/system/mutation-runner.ts +23 -0
  107. package/template/apps/server/src/system/organization-postgres.integration.test.ts +176 -0
  108. package/template/apps/server/src/system/resource-access-response.ts +12 -6
  109. package/template/apps/server/src/system/role-department-scope-policy.ts +55 -0
  110. package/template/apps/server/src/system/route-helpers.ts +2 -5
  111. package/template/apps/server/src/system/route-types.ts +12 -3
  112. package/template/apps/server/src/system/routes-validation.test.ts +26 -0
  113. package/template/apps/server/src/system/routes.test.ts +10 -2
  114. package/template/apps/server/src/system/routes.ts +70 -36
  115. package/template/apps/server/src/system/seed.ts +7 -0
  116. package/template/apps/server/src/work-order/data-scope.integration.test.ts +9 -2
  117. package/template/apps/server/src/work-order/opensearch-index.test.ts +97 -0
  118. package/template/apps/server/src/work-order/opensearch-index.ts +313 -0
  119. package/template/apps/server/src/work-order/search-provider.ts +60 -1
  120. package/template/apps/server/src/work-order/service.ts +42 -10
  121. package/template/apps/web/src/app/error-pages.ts +9 -0
  122. package/template/apps/web/src/components/auth/PasswordChangeForm.vue +16 -11
  123. package/template/apps/web/src/components/auth/PasswordChangeForm.webtest.ts +14 -0
  124. package/template/apps/web/src/components/common/FormDialog.vue +16 -2
  125. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +20 -1
  126. package/template/apps/web/src/composables/useGlobalSearch.ts +68 -0
  127. package/template/apps/web/src/composables/usePlatformSettings.ts +8 -7
  128. package/template/apps/web/src/layout/GlobalSearchResults.vue +58 -0
  129. package/template/apps/web/src/layout/NavigationSearchDialog.vue +42 -6
  130. package/template/apps/web/src/layout/NavigationSearchDialog.webtest.ts +71 -0
  131. package/template/apps/web/src/layout/NotificationMenu.vue +73 -31
  132. package/template/apps/web/src/layout/NotificationMenu.webtest.ts +44 -1
  133. package/template/apps/web/src/layout/NotificationMenuItem.vue +61 -0
  134. package/template/apps/web/src/layout/NotificationPreferencesDialog.vue +181 -0
  135. package/template/apps/web/src/layout/NotificationPreferencesDialog.webtest.ts +80 -0
  136. package/template/apps/web/src/lib/date-time.ts +22 -10
  137. package/template/apps/web/src/lib/date-time.webtest.ts +9 -1
  138. package/template/apps/web/src/main.ts +14 -1
  139. package/template/apps/web/src/pages/auth/ChangePasswordPage.vue +16 -1
  140. package/template/apps/web/src/pages/configuration/components/ModuleConfigVersionList.vue +1 -0
  141. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationEditor.vue +10 -0
  142. package/template/apps/web/src/pages/configuration/components/ModuleConfigurationWorkbench.vue +37 -6
  143. package/template/apps/web/src/pages/errors/PlatformErrorPage.vue +115 -0
  144. package/template/apps/web/src/pages/errors/PlatformErrorPage.webtest.ts +44 -0
  145. package/template/apps/web/src/pages/errors/error-page-extension.ts +26 -0
  146. package/template/apps/web/src/pages/system/SystemNotificationsPage.vue +136 -68
  147. package/template/apps/web/src/pages/system/SystemNotificationsPage.webtest.ts +224 -12
  148. package/template/apps/web/src/pages/system/audit-log-presenter.ts +86 -0
  149. package/template/apps/web/src/pages/system/audit-time-range.ts +28 -0
  150. package/template/apps/web/src/pages/system/components/AuditActionCell.vue +10 -69
  151. package/template/apps/web/src/pages/system/components/AuditActionCell.webtest.ts +20 -27
  152. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.vue +134 -0
  153. package/template/apps/web/src/pages/system/components/AuditLogDetailDialog.webtest.ts +76 -0
  154. package/template/apps/web/src/pages/system/components/AuditLogFilters.vue +116 -52
  155. package/template/apps/web/src/pages/system/components/AuditLogFilters.webtest.ts +52 -15
  156. package/template/apps/web/src/pages/system/components/NotificationHistoryDialog.vue +129 -0
  157. package/template/apps/web/src/pages/system/components/NotificationManagementCard.vue +91 -0
  158. package/template/apps/web/src/pages/system/components/NotificationPublishDialog.vue +121 -0
  159. package/template/apps/web/src/pages/system/components/NotificationPublishForm.vue +162 -1
  160. package/template/apps/web/src/pages/system/components/SystemResourceActions.vue +68 -0
  161. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +74 -0
  162. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +3 -4
  163. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +91 -98
  164. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +68 -0
  165. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +20 -1
  166. package/template/apps/web/src/pages/system/components/SystemResourceTable.webtest.ts +28 -7
  167. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +210 -0
  168. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +112 -0
  169. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +17 -4
  170. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +79 -0
  171. package/template/apps/web/src/pages/system/composables/useNotificationPermissions.ts +14 -0
  172. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +2 -0
  173. package/template/apps/web/src/pages/system/composables/useSystemResource.webtest.ts +15 -2
  174. package/template/apps/web/src/pages/system/resource-config.ts +12 -4
  175. package/template/apps/web/src/router/error-navigation.ts +50 -0
  176. package/template/apps/web/src/router/error-navigation.webtest.ts +106 -0
  177. package/template/apps/web/src/router/index.ts +71 -0
  178. package/template/apps/web/src/router/index.webtest.ts +29 -0
  179. package/template/apps/web/src/router/manifest-routes.webtest.ts +4 -0
  180. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  181. package/template/apps/web/src/services/auth-api.ts +5 -0
  182. package/template/apps/web/src/services/http.ts +43 -6
  183. package/template/apps/web/src/services/http.webtest.ts +70 -0
  184. package/template/apps/web/src/services/notification-api.ts +85 -2
  185. package/template/apps/web/src/services/notification-api.webtest.ts +110 -0
  186. package/template/apps/web/src/services/search-api.ts +39 -0
  187. package/template/apps/web/src/services/system-api.ts +18 -0
  188. package/template/apps/web/vitest.config.ts +1 -0
  189. package/template/docker-compose.yml +66 -0
  190. package/template/docs/architecture/ai-runtime.md +14 -5
  191. package/template/docs/architecture/search-provider-protocol.md +38 -2
  192. package/template/docs/development/custom-error-pages.md +78 -0
  193. package/template/docs/development/custom-login-page.md +3 -3
  194. package/template/docs/platform/agent-runtime.md +32 -6
  195. package/template/docs/platform/audit-reliability.md +46 -2
  196. package/template/docs/platform/module-configuration.md +13 -4
  197. package/template/docs/platform/notifications.md +67 -11
  198. package/template/docs/platform/organization-membership.md +17 -3
  199. package/template/docs/platform/system-crud-api.md +16 -0
  200. package/template/docs/platform/web-system-crud.md +6 -2
  201. package/template/package.json +4 -0
  202. package/template/packages/config/index.test.ts +45 -0
  203. package/template/packages/config/package.json +1 -1
  204. package/template/packages/config/src/platform.ts +169 -48
  205. package/template/packages/database/drizzle/0035_material_nightshade.sql +10 -0
  206. package/template/packages/database/drizzle/0036_hesitant_speed.sql +35 -0
  207. package/template/packages/database/drizzle/0037_absent_nick_fury.sql +2 -0
  208. package/template/packages/database/drizzle/0038_uneven_wasp.sql +38 -0
  209. package/template/packages/database/drizzle/meta/0035_snapshot.json +7331 -0
  210. package/template/packages/database/drizzle/meta/0036_snapshot.json +7436 -0
  211. package/template/packages/database/drizzle/meta/0037_snapshot.json +7457 -0
  212. package/template/packages/database/drizzle/meta/0038_snapshot.json +7749 -0
  213. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  214. package/template/packages/database/package.json +1 -1
  215. package/template/packages/database/src/schema/identity.ts +7 -0
  216. package/template/packages/database/src/schema/notifications.ts +135 -0
  217. package/template/packages/database/src/schema-notification-delivery.test.ts +54 -0
  218. package/template/packages/database/src/schema-workflow-notification.test.ts +40 -1
  219. package/template/packages/modules/package.json +1 -1
  220. package/template/packages/modules/src/system-api-permissions.ts +54 -0
  221. package/template/packages/modules/src/system-audit-actions.ts +10 -0
  222. package/template/packages/modules/src/system-features.ts +1 -1
  223. package/template/packages/modules/src/system-module-catalog.ts +2 -1
  224. package/template/packages/modules/src/system-modules.test.ts +13 -1
  225. package/template/packages/modules/src/system-modules.ts +12 -0
  226. package/template/packages/modules/src/system-permissions.ts +9 -5
  227. package/template/packages/server-sdk/package.json +1 -1
  228. package/template/packages/shared/package.json +1 -1
  229. package/template/packages/shared/src/audit.ts +25 -0
  230. package/template/packages/shared/src/module-configuration.ts +5 -0
  231. package/template/packages/shared/src/notification.ts +92 -1
  232. package/template/packages/shared/src/password.ts +7 -2
  233. package/template/packages/shared/src/platform-settings.ts +2 -0
  234. package/template/packages/storage/package.json +1 -1
  235. package/template/packages/storage/src/upload-service.test.ts +16 -0
  236. package/template/packages/storage/src/upload-service.ts +5 -1
  237. package/template/scripts/notification-postgres-regression.ts +27 -0
@@ -15,6 +15,10 @@ class FakeRouteApp {
15
15
  this.handlers.set(`POST ${path}`, handler);
16
16
  }
17
17
 
18
+ put(path: string, handler: (context: unknown) => unknown) {
19
+ this.handlers.set(`PUT ${path}`, handler);
20
+ }
21
+
18
22
  delete(path: string, handler: (context: unknown) => unknown) {
19
23
  this.handlers.set(`DELETE ${path}`, handler);
20
24
  }
@@ -148,4 +152,182 @@ describe("站内通知闭环", () => {
148
152
 
149
153
  expect(response.status).toBe(403);
150
154
  });
155
+
156
+ test("收藏和取消收藏路由幂等且只审计真实状态变化", async () => {
157
+ const runtime = createServerRuntime({ WINDY_DEV_ADMIN_TOKEN: "token_1" });
158
+ const app = new FakeRouteApp();
159
+ registerNotificationRoutes(
160
+ app,
161
+ new InMemoryNotificationRepository(),
162
+ runtime,
163
+ createFoundationSnapshot().features,
164
+ );
165
+ const guardContext = await runtime.createGuardContext(
166
+ new Request("http://localhost/api/system/notifications", {
167
+ headers: { authorization: "Bearer token_1" },
168
+ }),
169
+ );
170
+ const published = (await app.invoke("POST", "/system/notifications", {
171
+ guardContext,
172
+ params: {},
173
+ query: {},
174
+ body: {
175
+ title: "收藏测试",
176
+ summary: "收藏测试摘要",
177
+ content: "收藏测试正文",
178
+ category: "系统",
179
+ },
180
+ })) as { id: string };
181
+
182
+ const first = await app.invoke("PUT", "/notifications/:id/favorite", {
183
+ guardContext,
184
+ params: { id: published.id },
185
+ query: {},
186
+ });
187
+ const repeated = await app.invoke("PUT", "/notifications/:id/favorite", {
188
+ guardContext,
189
+ params: { id: published.id },
190
+ query: {},
191
+ });
192
+ const removed = await app.invoke("DELETE", "/notifications/:id/favorite", {
193
+ guardContext,
194
+ params: { id: published.id },
195
+ query: {},
196
+ });
197
+ const repeatedRemove = await app.invoke(
198
+ "DELETE",
199
+ "/notifications/:id/favorite",
200
+ {
201
+ guardContext,
202
+ params: { id: published.id },
203
+ query: {},
204
+ },
205
+ );
206
+
207
+ expect(first).toMatchObject({ favorite: true, changed: true });
208
+ expect(repeated).toMatchObject({ favorite: true, changed: false });
209
+ expect(removed).toEqual({
210
+ success: true,
211
+ favorite: false,
212
+ changed: true,
213
+ });
214
+ expect(repeatedRemove).toEqual({
215
+ success: true,
216
+ favorite: false,
217
+ changed: false,
218
+ });
219
+ expect(runtime.auditSink.list().map(({ action }) => action)).toEqual([
220
+ "notification.publish",
221
+ "notification.favorite",
222
+ "notification.unfavorite",
223
+ ]);
224
+ });
225
+
226
+ test("非法收件箱筛选被拒绝", async () => {
227
+ const runtime = createServerRuntime({ WINDY_DEV_ADMIN_TOKEN: "token_1" });
228
+ const app = new FakeRouteApp();
229
+ registerNotificationRoutes(
230
+ app,
231
+ new InMemoryNotificationRepository(),
232
+ runtime,
233
+ createFoundationSnapshot().features,
234
+ );
235
+ const guardContext = await runtime.createGuardContext(
236
+ new Request("http://localhost/api/notifications", {
237
+ headers: { authorization: "Bearer token_1" },
238
+ }),
239
+ );
240
+
241
+ const response = (await app.invoke("GET", "/notifications", {
242
+ guardContext,
243
+ params: {},
244
+ query: { read: "all", favorite: "yes" },
245
+ })) as Response;
246
+
247
+ expect(response.status).toBe(400);
248
+ expect(await response.json()).toMatchObject({
249
+ error: "INVALID_NOTIFICATION_FILTER",
250
+ });
251
+ });
252
+
253
+ test("system.notification Feature 关闭时收件箱 Guard 拒绝", async () => {
254
+ const runtime = createServerRuntime(
255
+ { WINDY_DEV_ADMIN_TOKEN: "token_1" },
256
+ {
257
+ featureResolver: {
258
+ resolve(key) {
259
+ return { key, enabled: false, visible: "hidden" };
260
+ },
261
+ },
262
+ },
263
+ );
264
+ const app = new FakeRouteApp();
265
+ registerNotificationRoutes(
266
+ app,
267
+ new InMemoryNotificationRepository(),
268
+ runtime,
269
+ createFoundationSnapshot().features,
270
+ );
271
+ const guardContext = await runtime.createGuardContext(
272
+ new Request("http://localhost/api/notifications", {
273
+ headers: { authorization: "Bearer token_1" },
274
+ }),
275
+ );
276
+
277
+ const response = (await app.invoke("GET", "/notifications", {
278
+ guardContext,
279
+ params: {},
280
+ query: {},
281
+ })) as Response;
282
+
283
+ expect(response.status).toBe(403);
284
+ expect(await response.json()).toMatchObject({ reason: "feature-disabled" });
285
+ });
286
+
287
+ test("猜测不可见通知 ID 不能通过收藏 API 探测受众", async () => {
288
+ const runtime = createServerRuntime();
289
+ const repository = new InMemoryNotificationRepository();
290
+ const app = new FakeRouteApp();
291
+ registerNotificationRoutes(
292
+ app,
293
+ repository,
294
+ runtime,
295
+ createFoundationSnapshot().features,
296
+ );
297
+ const actor = {
298
+ id: "outsider",
299
+ type: "user" as const,
300
+ name: "外部用户",
301
+ roleCodes: ["guest"],
302
+ permissionKeys: ["system.notification.read"],
303
+ };
304
+ const guardContext = await runtime.createGuardContextForActor(
305
+ new Request("http://localhost/api/notifications/targeted/favorite"),
306
+ actor,
307
+ );
308
+ const targeted = await repository.publish(
309
+ {
310
+ title: "定向通知",
311
+ summary: "仅指定用户可见",
312
+ content: "不可通过 ID 猜测访问",
313
+ category: "审批",
314
+ recipientUserId: "recipient",
315
+ },
316
+ { ...actor, id: "publisher" },
317
+ );
318
+
319
+ for (const method of ["PUT", "DELETE"]) {
320
+ const response = (await app.invoke(
321
+ method,
322
+ "/notifications/:id/favorite",
323
+ {
324
+ guardContext,
325
+ params: { id: targeted.id },
326
+ query: {},
327
+ },
328
+ )) as Response;
329
+ expect(response.status).toBe(404);
330
+ expect(await response.json()).toMatchObject({ error: "NOT_FOUND" });
331
+ }
332
+ });
151
333
  });
@@ -1,27 +1,25 @@
1
- import { randomUUIDv7 } from "bun";
2
- import type {
3
- AuditActionType,
4
- FeatureFlagDefinition,
5
- HttpMethod,
6
- PublishNotificationInput,
7
- } from "@southwind-ai/shared";
8
- import type { RequestGuardContext } from "../guards.js";
1
+ import type { FeatureFlagDefinition, HttpMethod } from "@southwind-ai/shared";
9
2
  import { evaluateRouteGuards, findSystemFeature } from "../route-guards.js";
10
3
  import type { createServerRuntime } from "../runtime.js";
11
4
  import type { NotificationRepository } from "./repository.js";
5
+ import type { NotificationService } from "./service.js";
6
+ import { registerNotificationExtensionRoutes } from "./extension-routes.js";
12
7
  import { parsePagination } from "../pagination.js";
8
+ import {
9
+ notificationEvent,
10
+ notificationNotFound,
11
+ parseInboxFilters,
12
+ parsePublishInput,
13
+ parseUpdateInput,
14
+ type NotificationRouteContext,
15
+ } from "./route-support.js";
13
16
 
14
17
  type ServerRuntime = ReturnType<typeof createServerRuntime>;
15
- type RouteContext = {
16
- body?: unknown;
17
- guardContext: RequestGuardContext;
18
- params: Record<string, string | undefined>;
19
- query: Record<string, string | undefined>;
20
- };
21
18
 
22
- interface NotificationRouteApp {
19
+ export interface NotificationRouteApp {
23
20
  get(path: string, handler: (context: unknown) => unknown): unknown;
24
21
  post(path: string, handler: (context: unknown) => unknown): unknown;
22
+ put(path: string, handler: (context: unknown) => unknown): unknown;
25
23
  delete(path: string, handler: (context: unknown) => unknown): unknown;
26
24
  }
27
25
 
@@ -30,86 +28,243 @@ export function registerNotificationRoutes(
30
28
  repository: NotificationRepository,
31
29
  runtime: ServerRuntime,
32
30
  features: FeatureFlagDefinition[] = [],
31
+ service?: NotificationService,
33
32
  ) {
34
33
  const feature = findSystemFeature(features, "notification");
35
34
 
36
35
  app.get("/notifications", (raw) =>
37
- guarded(raw, "GET", "read", feature, runtime, async (context) => {
38
- const pagination = parsePagination(context.query);
39
- if (pagination instanceof Response) return pagination;
40
- return repository.listInbox(
41
- context.guardContext.actor,
42
- pagination.page,
43
- pagination.pageSize,
44
- );
45
- }),
36
+ guarded(
37
+ raw,
38
+ "GET",
39
+ "/api/notifications",
40
+ "read",
41
+ feature,
42
+ runtime,
43
+ async (context) => {
44
+ const pagination = parsePagination(context.query);
45
+ if (pagination instanceof Response) return pagination;
46
+ const filters = parseInboxFilters(context.query);
47
+ if (filters instanceof Response) return filters;
48
+ return repository.listInbox(
49
+ context.guardContext.actor,
50
+ pagination.page,
51
+ pagination.pageSize,
52
+ filters,
53
+ );
54
+ },
55
+ ),
46
56
  );
47
57
  app.post("/notifications/:id/read", (raw) =>
48
- guarded(raw, "POST", "read", feature, runtime, async (context) => {
49
- const id = context.params.id || "";
50
- const result = await repository.markRead(id, context.guardContext.actor);
51
- if (!result) return notFound(id);
52
- if (!result.alreadyRead) {
53
- runtime.auditSink.recordEvent(event(context, "notification.read", id));
54
- }
55
- return result;
56
- }),
58
+ guarded(
59
+ raw,
60
+ "POST",
61
+ "/api/notifications/:id/read",
62
+ "read",
63
+ feature,
64
+ runtime,
65
+ async (context) => {
66
+ const id = context.params.id || "";
67
+ const result = await repository.markRead(
68
+ id,
69
+ context.guardContext.actor,
70
+ );
71
+ if (!result) return notificationNotFound(id);
72
+ if (!result.alreadyRead) {
73
+ runtime.auditSink.recordEvent(
74
+ notificationEvent(context, "notification.read", id),
75
+ );
76
+ }
77
+ return result;
78
+ },
79
+ ),
80
+ );
81
+ app.put("/notifications/:id/favorite", (raw) =>
82
+ guarded(
83
+ raw,
84
+ "PUT",
85
+ "/api/notifications/:id/favorite",
86
+ "read",
87
+ feature,
88
+ runtime,
89
+ async (context) => {
90
+ const id = context.params.id || "";
91
+ const result = await repository.setFavorite(
92
+ id,
93
+ true,
94
+ context.guardContext.actor,
95
+ );
96
+ if (!result) return notificationNotFound(id);
97
+ if (result.changed) {
98
+ runtime.auditSink.recordEvent(
99
+ notificationEvent(context, "notification.favorite", id),
100
+ );
101
+ }
102
+ return result;
103
+ },
104
+ ),
105
+ );
106
+ app.delete("/notifications/:id/favorite", (raw) =>
107
+ guarded(
108
+ raw,
109
+ "DELETE",
110
+ "/api/notifications/:id/favorite",
111
+ "read",
112
+ feature,
113
+ runtime,
114
+ async (context) => {
115
+ const id = context.params.id || "";
116
+ const result = await repository.setFavorite(
117
+ id,
118
+ false,
119
+ context.guardContext.actor,
120
+ );
121
+ if (!result) return notificationNotFound(id);
122
+ if (result.changed) {
123
+ runtime.auditSink.recordEvent(
124
+ notificationEvent(context, "notification.unfavorite", id),
125
+ );
126
+ }
127
+ return result;
128
+ },
129
+ ),
57
130
  );
58
131
  app.get("/system/notifications", (raw) =>
59
- guarded(raw, "GET", "manage", feature, runtime, async (context) => {
60
- const pagination = parsePagination(context.query);
61
- if (pagination instanceof Response) return pagination;
62
- return repository.listManaged(
63
- pagination.page,
64
- pagination.pageSize,
65
- context.query.q,
66
- );
67
- }),
132
+ guarded(
133
+ raw,
134
+ "GET",
135
+ "/api/system/notifications",
136
+ "manage",
137
+ feature,
138
+ runtime,
139
+ async (context) => {
140
+ const pagination = parsePagination(context.query);
141
+ if (pagination instanceof Response) return pagination;
142
+ return repository.listManaged(
143
+ pagination.page,
144
+ pagination.pageSize,
145
+ context.query.q,
146
+ );
147
+ },
148
+ ),
149
+ );
150
+ app.get("/system/notifications/:id/revisions", (raw) =>
151
+ guarded(
152
+ raw,
153
+ "GET",
154
+ "/api/system/notifications/:id/revisions",
155
+ "manage",
156
+ feature,
157
+ runtime,
158
+ async (context) => {
159
+ const pagination = parsePagination(context.query);
160
+ if (pagination instanceof Response) return pagination;
161
+ const id = context.params.id || "";
162
+ const revisions = await repository.listRevisions(
163
+ id,
164
+ pagination.page,
165
+ pagination.pageSize,
166
+ );
167
+ return revisions || notificationNotFound(id);
168
+ },
169
+ ),
68
170
  );
69
171
  app.post("/system/notifications", (raw) =>
70
- guarded(raw, "POST", "manage", feature, runtime, async (context) => {
71
- const input = parsePublishInput(context.body);
72
- if (input instanceof Response) return input;
73
- const notification = await repository.publish(
74
- input,
75
- context.guardContext.actor,
76
- );
77
- await runtime.auditSink.recordCriticalEvent(
78
- event(context, "notification.publish", notification.id),
79
- );
80
- return notification;
81
- }),
172
+ guarded(
173
+ raw,
174
+ "POST",
175
+ "/api/system/notifications",
176
+ "manage",
177
+ feature,
178
+ runtime,
179
+ async (context) => {
180
+ const input = parsePublishInput(context.body);
181
+ if (input instanceof Response) return input;
182
+ const notification = await (service || repository).publish(
183
+ input,
184
+ context.guardContext.actor,
185
+ );
186
+ await runtime.auditSink.recordCriticalEvent(
187
+ notificationEvent(
188
+ context,
189
+ notification.status === "scheduled"
190
+ ? "notification.schedule"
191
+ : "notification.publish",
192
+ notification.id,
193
+ ),
194
+ );
195
+ return notification;
196
+ },
197
+ ),
198
+ );
199
+ app.put("/system/notifications/:id", (raw) =>
200
+ guarded(
201
+ raw,
202
+ "PUT",
203
+ "/api/system/notifications/:id",
204
+ "update",
205
+ feature,
206
+ runtime,
207
+ async (context) => {
208
+ const input = parseUpdateInput(context.body);
209
+ if (input instanceof Response) return input;
210
+ const id = context.params.id || "";
211
+ const result = await repository.update(
212
+ id,
213
+ input,
214
+ context.guardContext.actor,
215
+ );
216
+ if (!result) return notificationNotFound(id);
217
+ if (result.changed) {
218
+ await runtime.auditSink.recordCriticalEvent(
219
+ notificationEvent(context, "notification.update", id),
220
+ );
221
+ }
222
+ return result;
223
+ },
224
+ ),
82
225
  );
83
226
  app.delete("/system/notifications/:id", (raw) =>
84
- guarded(raw, "DELETE", "manage", feature, runtime, async (context) => {
85
- const id = context.params.id || "";
86
- const notification = await repository.archive(
87
- id,
88
- context.guardContext.actor,
89
- );
90
- if (!notification) return notFound(id);
91
- await runtime.auditSink.recordCriticalEvent(
92
- event(context, "notification.archive", id),
93
- );
94
- return notification;
95
- }),
227
+ guarded(
228
+ raw,
229
+ "DELETE",
230
+ "/api/system/notifications/:id",
231
+ "manage",
232
+ feature,
233
+ runtime,
234
+ async (context) => {
235
+ const id = context.params.id || "";
236
+ const notification = await repository.archive(
237
+ id,
238
+ context.guardContext.actor,
239
+ );
240
+ if (!notification) return notificationNotFound(id);
241
+ await runtime.auditSink.recordCriticalEvent(
242
+ notificationEvent(context, "notification.archive", id),
243
+ );
244
+ return notification;
245
+ },
246
+ ),
96
247
  );
248
+ if (service) {
249
+ registerNotificationExtensionRoutes(app, service, runtime, feature);
250
+ }
97
251
  return app;
98
252
  }
99
253
 
100
254
  async function guarded(
101
255
  raw: unknown,
102
256
  method: HttpMethod,
103
- action: "read" | "manage",
257
+ path: string,
258
+ action: "read" | "update" | "manage",
104
259
  feature: FeatureFlagDefinition | undefined,
105
260
  runtime: ServerRuntime,
106
- handler: (context: RouteContext) => unknown,
261
+ handler: (context: NotificationRouteContext) => unknown,
107
262
  ) {
108
- const context = raw as RouteContext;
263
+ const context = raw as NotificationRouteContext;
109
264
  const decision = await evaluateRouteGuards(runtime, context.guardContext, {
110
265
  permission: {
111
266
  method,
112
- path: "/api/notifications",
267
+ path,
113
268
  permissionKey: `system.notification.${action}`,
114
269
  },
115
270
  feature,
@@ -121,65 +276,3 @@ async function guarded(
121
276
  { status: 403 },
122
277
  );
123
278
  }
124
-
125
- function parsePublishInput(body: unknown): PublishNotificationInput | Response {
126
- const value =
127
- body && typeof body === "object" ? (body as Record<string, unknown>) : {};
128
- const input = {
129
- title: text(value.title),
130
- summary: text(value.summary),
131
- content: text(value.content),
132
- category: text(value.category),
133
- };
134
- if (!input.title || !input.summary || !input.content || !input.category) {
135
- return Response.json(
136
- {
137
- error: "INVALID_NOTIFICATION",
138
- message: "标题、摘要、正文和分类均不能为空",
139
- },
140
- { status: 400 },
141
- );
142
- }
143
- if (
144
- input.title.length > 120 ||
145
- input.summary.length > 240 ||
146
- input.content.length > 10_000 ||
147
- input.category.length > 40
148
- ) {
149
- return Response.json(
150
- { error: "INVALID_NOTIFICATION", message: "通知内容超过长度限制" },
151
- { status: 400 },
152
- );
153
- }
154
- return input;
155
- }
156
-
157
- function event(context: RouteContext, action: AuditActionType, id: string) {
158
- return {
159
- id: randomUUIDv7(),
160
- action,
161
- outcome: "success" as const,
162
- actor: {
163
- id: context.guardContext.actor.id,
164
- type:
165
- context.guardContext.actor.type === "anonymous"
166
- ? ("user" as const)
167
- : context.guardContext.actor.type,
168
- name: context.guardContext.actor.name,
169
- },
170
- target: { type: "notification", id },
171
- requestId: context.guardContext.requestId,
172
- occurredAt: new Date().toISOString(),
173
- };
174
- }
175
-
176
- function notFound(id: string) {
177
- return Response.json(
178
- { error: "NOT_FOUND", resource: "notification", id },
179
- { status: 404 },
180
- );
181
- }
182
-
183
- function text(value: unknown) {
184
- return typeof value === "string" ? value.trim() : "";
185
- }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { NotificationRecipientDirectory } from "./recipient-directory.js";
3
+ import { InMemoryNotificationDeliveryRepository } from "./memory-delivery-repository.js";
4
+ import { InMemoryNotificationPreferenceRepository } from "./preference-repository.js";
5
+ import { InMemoryNotificationRepository } from "./repository.js";
6
+ import { NotificationService } from "./service.js";
7
+
8
+ const recipients: NotificationRecipientDirectory = {
9
+ async options() {
10
+ return {
11
+ users: [{ id: "u1", label: "用户一" }],
12
+ roles: [],
13
+ departments: [],
14
+ };
15
+ },
16
+ async resolve() {
17
+ return [{ id: "u1", email: "u1@example.com" }];
18
+ },
19
+ async address() {
20
+ return "u1@example.com";
21
+ },
22
+ };
23
+
24
+ describe("NotificationService", () => {
25
+ test("普通通知只为用户启用且发布者请求的渠道创建 outbox", async () => {
26
+ const fixture = createFixture();
27
+ await fixture.preferences.save(
28
+ "u1",
29
+ { enabledChannels: ["email"], disabledCategories: [] },
30
+ "u1",
31
+ );
32
+ const notification = await fixture.service.publish(
33
+ {
34
+ title: "运营通知",
35
+ summary: "摘要",
36
+ content: "正文",
37
+ category: "运营",
38
+ deliveryChannels: ["email", "sms"],
39
+ },
40
+ actor,
41
+ );
42
+ expect(notification.status).toBe("published");
43
+ expect(await fixture.deliveries.list(notification.id)).toMatchObject([
44
+ { userId: "u1", channel: "email", status: "pending" },
45
+ ]);
46
+ });
47
+
48
+ test("定时通知到期前不入 outbox,到期后幂等激活并入队", async () => {
49
+ const fixture = createFixture();
50
+ await fixture.preferences.save(
51
+ "u1",
52
+ { enabledChannels: ["email"], disabledCategories: [] },
53
+ "u1",
54
+ );
55
+ const scheduledFor = "2030-01-01T00:00:00.000Z";
56
+ const notification = await fixture.service.publish(
57
+ {
58
+ title: "定时通知",
59
+ summary: "摘要",
60
+ content: "正文",
61
+ category: "运营",
62
+ deliveryChannels: ["email"],
63
+ scheduledFor,
64
+ },
65
+ actor,
66
+ );
67
+ expect(notification.status).toBe("scheduled");
68
+ expect(await fixture.deliveries.list(notification.id)).toEqual([]);
69
+ expect(
70
+ await fixture.service.dispatchScheduled(
71
+ new Date("2030-01-01T00:00:01.000Z"),
72
+ ),
73
+ ).toEqual({ claimed: 1, published: 1, deliveries: 1 });
74
+ expect(
75
+ await fixture.service.dispatchScheduled(
76
+ new Date("2030-01-01T00:00:02.000Z"),
77
+ ),
78
+ ).toEqual({ claimed: 0, published: 0, deliveries: 0 });
79
+ });
80
+ });
81
+
82
+ function createFixture() {
83
+ const preferences = new InMemoryNotificationPreferenceRepository();
84
+ const deliveries = new InMemoryNotificationDeliveryRepository();
85
+ return {
86
+ preferences,
87
+ deliveries,
88
+ service: new NotificationService(
89
+ new InMemoryNotificationRepository(),
90
+ preferences,
91
+ deliveries,
92
+ recipients,
93
+ { mandatoryCategories: ["安全"] },
94
+ ),
95
+ };
96
+ }
97
+
98
+ const actor = {
99
+ id: "admin",
100
+ type: "user" as const,
101
+ name: "管理员",
102
+ roleCodes: ["admin"],
103
+ permissionKeys: ["system.notification.manage"],
104
+ };