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
@@ -2,32 +2,88 @@
2
2
 
3
3
  ## 产品行为
4
4
 
5
- 通知管理页 `/system/notifications` 提供广播发布与历史管理。当前一条通知发布后,会进入所有同时具备 `system.notification.read` 权限且 `system.notification` Feature 可用的用户收件箱。Header 点击通知后打开完整正文,并为当前用户写入跨设备共享的已读回执。
5
+ 通知管理页 `/system/notifications` 提供广播或定向发布、定时发布、已发布公告修改、不可变版本历史、外部投递状态与归档管理,不是普通用户收件箱。能否修改只取决于当前 Actor 是否通过角色获得独立的 `system.notification.update` 权限;管理页访问、发布、历史查询和归档继续使用 `system.notification.manage`。普通 `system.notification.read` 用户可以在收件箱阅读、筛选、标记已读、维护个人收藏,并设置邮件、短信与分类偏好。
6
6
 
7
- 管理页通过页头的“发布新通知”按钮打开发布对话框,避免让常驻表单挤占历史列表。发布过程必须显式提交、点击取消或关闭按钮;点击遮罩层不会误关对话框和丢失已填写内容。对话框遵循全局布局约束:设置适合表单的最小高度且不超过当前屏幕,只有 Body 区域滚动,标题与底部操作始终可见。发布成功后对话框自动关闭并清空表单。
7
+ 普通用户收件箱由 `GET /api/notifications` 提供服务端分页与筛选:
8
+
9
+ - `page`、`pageSize` 保持原有契约,默认每页 10 条、上限 100 条;
10
+ - `q` 在标题、摘要、正文和分类中做不区分大小写的关键词匹配;
11
+ - `category` 按分类精确筛选;
12
+ - `read=read|unread` 筛选当前用户的已读或未读通知;
13
+ - `favorite=true|false` 筛选当前用户的收藏或未收藏通知。
14
+
15
+ 筛选条件使用 AND 组合。服务端先按当前 Actor 的用户 ID、角色和所属部门确定可见通知;部门受众可选择递归包含子部门。用户、角色和部门范围按“或”合并,全部留空时广播。列表查询与总数查询复用同一组条件,`total` 表示筛选后的可见总数,分页不会退化为客户端全量加载。
16
+
17
+ 管理页通过页头的“发布新通知”按钮打开发布对话框。管理员从服务端受控目录选择用户、角色或部门,可选择是否包含子部门、未来发布时间和外部渠道;API 不接受邮箱或手机号等任意投递地址。发布、偏好和编辑对话框都执行未保存保护:关闭、路由离开和浏览器刷新不会静默丢失输入,保存失败时留在原编辑态。
8
18
 
9
19
  管理列表与用户收件箱共享平台分页契约:默认每页 10 条,服务端上限 100;管理页允许输入范围内任意正整数页大小,并按跨页连续序号展示通知。已发布通知的刷新操作固定在列表右上角,仅显示刷新图标,通过立即出现的组件库 Tooltip 和 `aria-label` 提供名称;加载期间图标旋转且按钮不可重复提交。
10
20
 
11
- 通知管理页的人工发布范围仍是全体授权用户,不能通过普通发布 API 伪造定向受众。平台业务端口可以创建受控的用户或角色通知,并携带通用 `TargetRef` 业务目标;当前仅最小审批工作流接入。业务通知使用稳定 `sourceKey` 去重,收件箱查询和已读写入都按当前 Actor 的用户 ID 与角色集合执行最终可见性校验。
21
+ 管理员编辑标题、摘要、正文或分类后,服务端在同一事务中更新当前公告并追加新版本;客户端不能指定版本号、修改人或修改时间。内容完全相同时返回 `changed: false`,不会增加版本或重复写审计。并发修改通过行锁串行化,版本号从当前值单调递增。归档后的公告不再允许修改,但仍可分页查看全部历史。编辑对话框存在未保存内容时,关闭、切换路由、刷新或关闭标签页都会触发确认保护。
22
+
23
+ 平台业务端口仍可创建携带通用 `TargetRef` 的用户或角色通知;当前最小审批工作流使用稳定 `sourceKey` 去重。无论人工还是业务发布,收件箱查询、已读和收藏写入都会按当前 Actor 重新执行最终可见性校验。
12
24
 
13
- 组织范围、管理端定向/定时发布、用户偏好、策略化合并,以及邮件、短信、企业微信等渠道 Adapter 仍属于 P1,不在现有广播管理页或审批接入中伪装支持。
25
+ 站内通知始终保留,个人偏好只控制外部渠道。外部投递的确定顺序固定为:强制类别优先,其次类别退订,最后取“发布者请求渠道”与“用户启用渠道”的交集;未保存偏好时普通类别默认不向外部发送。当前默认把“安全”作为强制类别,但仍不会增加发布者未请求的渠道。收藏与已读不参与渠道策略。
26
+
27
+ 邮件渠道使用 Server 组合根注入的标准 SMTP Adapter,唯一配置来源是类型化 `platformConfig.mail`;`MAIL_SMTP_URL` 仍是 env-only Secret,不进入普通 API、页面、审计或 outbox。短信只定义类型化 `NotificationChannelAdapter` Port:平台不知道供应商报文时不猜格式,未注入 Adapter 的短信投递会明确进入 `dead / CHANNEL_NOT_CONFIGURED`,业务项目可在组合根注入供应商实现。
14
28
 
15
29
  ## 生命周期与数据
16
30
 
17
- - `platform_notifications` 保存标题、摘要、正文、分类、发布时间和审计字段;业务端口还可写入可选 TargetRef、用户/角色受众和唯一 source key。
31
+ - `platform_notifications` 保存内容、状态、计划/实际发布时间、用户/角色/部门受众、是否包含子部门、请求渠道和审计字段;业务端口还可写入 TargetRef 与唯一 source key。
32
+ - `platform_notifications.revision` 保存当前版本号;`notification_revisions` 以“通知 ID + 版本号”为复合主键保存每次完整内容快照、修改时间与修改人。发布即形成 v1,历史只追加不覆盖。
18
33
  - `notification_reads` 以“通知 ID + 用户 ID”为复合主键保存已读时间,重复点击幂等。
19
- - 管理操作使用归档而不是物理删除。归档后通知立即退出用户收件箱,但通知历史、操作者信息和既有已读回执继续保留。
34
+ - `notification_favorites` 以“通知 ID + 用户 ID”为复合主键保存收藏时间。收藏与已读是两份独立回执:收藏不会自动已读,阅读或取消收藏也不会改写另一份状态。
35
+ - `notification_preferences` 按用户保存启用的外部渠道和退订分类;客户端不能修改其他用户的偏好。
36
+ - `notification_delivery_outbox` 按“通知 + 用户 + 渠道”唯一保存持久投递。Worker 使用 `FOR UPDATE SKIP LOCKED`、lease token、过期租约回收和有界指数退避;成功进入 `delivered`,不可重试、地址缺失或超过上限进入 `dead`。outbox 只保存用户 ID 和通知内容,不保存 SMTP Secret,也不接受客户端地址快照。
37
+ - 管理操作使用归档而不是物理删除。归档后通知立即退出用户收件箱,但通知历史、操作者信息和既有已读、收藏回执继续保留。
20
38
  - 无数据库时仅允许开发态内存 Repository;生产部署必须配置 `DATABASE_URL` 并执行迁移。
21
39
 
40
+ ## 收藏 API 与安全边界
41
+
42
+ - `PUT /api/notifications/:id/favorite` 把当前用户状态设为收藏;
43
+ - `DELETE /api/notifications/:id/favorite` 把当前用户状态设为未收藏;
44
+ - 两个命令都幂等,响应中的 `changed` 只在本次请求真实改变状态时为 `true`;
45
+ - Repository 在写入或删除个人回执前必须重新校验通知仍为已发布状态且对当前 Actor 可见;
46
+ - 收藏记录始终使用 Guard 解析出的 Actor ID,客户端不能指定用户 ID;
47
+ - 未知、已归档或对 Actor 不可见的通知统一返回 `404`,避免通过猜测通知 ID 探测受众;
48
+ - 快速重复收藏只允许一个插入成功,快速重复取消收藏最多删除一次。
49
+
50
+ ## 定向、偏好与投递 API
51
+
52
+ - `GET /api/system/notifications/audience-options` 返回当前启用的用户、角色和部门最小投影,要求 `system.notification.manage`;
53
+ - `POST /api/system/notifications` 可提交受众、`includeChildDepartments`、`scheduledFor` 与 `deliveryChannels`;未来时间创建 `scheduled` 通知,调度器到期后以条件更新激活,重复 tick 不会重复发布或投递;
54
+ - `GET/PUT /api/notifications/preferences` 只读取或更新当前 Actor 偏好,要求 `system.notification.read`;
55
+ - `GET /api/system/notifications/:id/deliveries` 查询外部投递状态,要求 `system.notification.manage`;
56
+ - `system.notification.dispatch` 每 5 秒发布到期通知并领取 outbox。投递时再次从受控用户目录解析当前邮箱或手机号,停用用户或缺失地址不会被发送。
57
+
58
+ ## 修改与历史 API
59
+
60
+ - `PUT /api/system/notifications/:id` 修改仍处于已发布状态的公告,正文结构与发布接口兼容,响应为 `{ notification, changed }`;
61
+ - `GET /api/system/notifications/:id/revisions?page=1&pageSize=10` 按版本号倒序返回不可变历史、修改人和 ISO 8601 修改时间,并返回筛选前该公告的历史总数;
62
+ - 修改接口要求 `system.notification.update`,历史接口要求 `system.notification.manage`;两者都要求 `system.notification` Feature,缺少对应角色权限时返回 `403`;
63
+ - 未知公告返回 `404`;归档公告的修改也返回 `404`,避免暴露额外生命周期细节,历史查询仍可用;
64
+ - 修改输入只接受标题、摘要、正文和分类,沿用发布接口的必填与长度限制;Actor、时间和版本号全部由服务端生成。
65
+
22
66
  ## Feature、RBAC 与审计
23
67
 
24
68
  能力基础 Key 为 `system.notification`:
25
69
 
26
- - 用户收件箱和已读接口要求 `system.notification.read`;
27
- - 后台列表、发布和归档要求 `system.notification.manage`;
70
+ - 用户收件箱、已读和个人收藏接口要求 `system.notification.read`;
71
+ - 修改要求独立的 `system.notification.update`;后台列表、发布、历史查询和归档要求 `system.notification.manage`,两项权限可以分别赋予角色;
28
72
  - 菜单、Router、Header、Server API 都引用 Manifest 中的同一 Feature / Permission Key;
29
73
  - Feature 或权限不可用时 Header 隐藏且不发请求,Server 仍执行最终 Guard;
30
- - 普通管理发布只接受广播字段;受众和 TargetRef 只由经过组合根注入的业务端口生成,猜测通知 ID 不能绕过已读可见性检查;
31
- - 发布记录 `notification.publish`,首次阅读记录 `notification.read`,归档记录 `notification.archive`;重复已读不重复制造审计事件。
74
+ - 管理端受众只接受受控目录 ID/角色 code,不接受外部地址;猜测通知 ID 不能绕过已读或收藏可见性检查;
75
+ - 立即发布记录 `notification.publish`,定时创建记录 `notification.schedule`,偏好更新记录 `notification.preference.update`;修改、阅读、收藏和归档沿用既有审计动作。审计 metadata 不记录地址、正文或 SMTP Secret。
76
+
77
+ 发布、修改和归档使用关键审计写入策略。普通已读与收藏使用常规审计,避免审计存储短暂降级时阻止用户消费通知。
78
+
79
+ ## 升级与迁移
80
+
81
+ 个人收藏由 migration `0035_material_nightshade.sql` 引入,公告版本由 `0036_hesitant_speed.sql` 引入。`0038_uneven_wasp.sql` 新增定时/多受众/渠道字段、用户偏好和持久 outbox;旧通知默认保持立即发布、广播且不请求外部渠道。升级平台或通过 Windy 生命周期更新下游项目后:
82
+
83
+ 1. 先审阅生成边界与 `windy update --dry-run` 结果,不手工覆盖 Windy-managed 文件;
84
+ 2. 在切换新版 Server 前按生产发布流程备份数据库;
85
+ 3. 使用生产唯一入口 `DATABASE_URL=... bun run db:migrate` 应用最新 migration;
86
+ 4. 运行 `bun run test:db:migrations` 可验证 fresh migration 与重复执行幂等;
87
+ 5. migration 成功后再发布应用。旧客户端仍可只传 `page/pageSize`,无需同步上线筛选、收藏、修改或历史调用;旧公告首次修改会从已回填的 v1 继续递增。
32
88
 
33
- 发布和归档使用关键审计写入策略。普通已读使用常规审计,避免审计存储短暂降级时阻止用户阅读正文。
89
+ 真实 PostgreSQL 回归可执行 `bun run scripts/notification-postgres-regression.ts`,覆盖 fresh migration、组织子树可见性、偏好持久化、outbox lease 防伪与重建 Repository 后的投递状态。
@@ -4,7 +4,7 @@
4
4
 
5
5
  Windy V1 采用单组织、单租户的企业私有化模型。canonical schema 保留 RuoYi-Vue 兼容导入所需的部门树、用户主部门和角色自定义部门范围,但新模块不得直接依赖旧 `sys_dept` 或其它 `sys_*` 表。
6
6
 
7
- - `departments.parent_id` 表示上级部门,空值为根部门;
7
+ - `departments.parent_id` 表示上级部门,只有平台根组织为空;
8
8
  - `users.department_id` 表示用户唯一主部门;
9
9
  - `roles.data_scope` 和 `roles.department_ids` 表示角色数据范围策略;
10
10
  - 岗位、兼职部门和多组织成员关系属于后续独立模型,不复用 `department_id` 暗中表达。
@@ -20,7 +20,21 @@ Windy V1 采用单组织、单租户的企业私有化模型。canonical schema
20
20
  - `system.department.create`:调用受限的 `POST /api/system/departments/quick`;
21
21
  - `system.department.manage`:兼容既有管理员,调用通用部门创建接口。
22
22
 
23
- 快捷创建只接收名称、编码、可选上级和排序,成功后刷新部门列表并自动选中新部门。`system.department.create` 与既有 `system.department.read/manage` 使用同一能力基础 Key `system.department`,接口继续经过 `system.rbac` Feature Guard,并写入 `crud.create` 审计事件。没有创建或管理权限时,表单只显示部门选择,不渲染快捷入口。
23
+ 快捷创建只接收名称、编码、必选上级和排序,成功后刷新部门列表并自动选中新部门。`system.department.create` 与既有 `system.department.read/manage` 使用同一能力基础 Key `system.department`,接口继续经过 `system.rbac` Feature Guard,并写入 `crud.create` 审计事件。没有创建或管理权限时,表单只显示部门选择,不渲染快捷入口。
24
+
25
+ ## 组织树强不变量
26
+
27
+ Windy V1 只允许一个未删除根组织。`0037_absent_nick_fury.sql` 通过 PostgreSQL partial unique index 约束根节点数量,并用 check constraint 拒绝直接把部门设为自身上级;Service 在写入前继续覆盖数据库无法表达的整棵树规则:
28
+
29
+ - 新部门必须选择一个启用上级,不能通过通用或快捷 API 创建第二个根;
30
+ - 根组织不能移动、停用或删除;
31
+ - 部门不能移动到自身或任意下级,异常旧树的遍历使用 visited set 有界终止;
32
+ - 角色 `custom` 数据范围至少选择一个启用部门,未知或停用部门返回 `INVALID_ROLE_DEPARTMENT_SCOPE`;
33
+ - 部门仍有直接下级、未删除用户或角色自定义部门范围时,普通停用和删除返回 `DEPARTMENT_MIGRATION_REQUIRED`,响应包含三类引用计数。
34
+
35
+ 管理端处置部门使用 `POST /api/system/departments/:id/transition`。请求明确给出 `disable` 或 `delete` 以及启用的迁移目标;Server 会拒绝当前节点和其下级作为目标,然后把直接下级的 `parentId`、用户主部门和角色 `departmentIds` 一起迁移,再完成停用或软删除。操作写入 `organization.department.transition` 审计事件,元数据只记录动作、目标 ID 和迁移计数。
36
+
37
+ 用户、角色和部门写操作共享同一个组织 mutation coordinator。无数据库开发模式使用进程内串行;PostgreSQL 使用固定 advisory transaction lock,因此多个 Server 实例的互相移动、用户归属写入和部门处置也按同一事实顺序判定。`bun run test:db:organization` 在 PostgreSQL 17 覆盖唯一根约束、双实例循环竞态和用户归属竞态;`bun run test:db:data-scope` 继续验证组织树调整后数据范围查询不漂移。
24
38
 
25
39
  ## 权限边界
26
40
 
@@ -28,4 +42,4 @@ Windy V1 采用单组织、单租户的企业私有化模型。canonical schema
28
42
 
29
43
  当前用户资源已接入类型化数据访问上下文。Actor Resolver 从全部启用角色生成 `all`、`tenant`、`department`、`department-and-children`、`self` 或 `custom` grant;多个角色按可见集合并集计算。用户列表、总数、搜索、分页、导出、按 ID 读取、更新、删除和组织移动使用同一约束,跨部门猜测 ID 返回稳定的 `data-scope-denied`,不会只依赖 Web 隐藏。
30
44
 
31
- 详细接入契约见 [数据访问范围](./data-access-control.md)。V1 当前只承诺用户资源已经闭环;没有明确 `ownerId` / `departmentId` 归属的资源不会自动套用部门范围。部门树循环、唯一根节点、含下级或用户节点的停用/删除/迁移策略仍属于 `ORG-02`,不能从当前后代解析能力推导为已经完成。
45
+ 详细接入契约见 [数据访问范围](./data-access-control.md)。没有明确 `ownerId` / `departmentId` 归属的资源不会自动套用部门范围。ORG-02 只覆盖单组织、用户唯一主部门和角色自定义部门范围;岗位、兼职部门与多组织成员关系仍是明确的后续模型,不在本轮暗中扩展。
@@ -8,6 +8,7 @@ Server 端已提供系统基础模块 Repository API,用于打通权限、查
8
8
  默认无数据库时使用内存 Repository;配置 `DATABASE_URL` 后,系统资源 CRUD 会切换为 Drizzle Repository,并把用户、角色、部门、菜单、字典和功能开关持久化到 PostgreSQL。
9
9
 
10
10
  <!-- @windy-module system.license begin -->
11
+
11
12
  选择离线 License 模块时,原型 License 兼容记录也会持久化到 PostgreSQL。
12
13
  <!-- @windy-module system.license end -->
13
14
 
@@ -19,9 +20,13 @@ Server 端已提供系统基础模块 Repository API,用于打通权限、查
19
20
  - `menus`:菜单。
20
21
  - `dictionaries`:字典。
21
22
  - `features`:功能开关。
23
+
22
24
  <!-- @windy-module system.license begin -->
25
+
23
26
  - `licenses`:License 记录。
27
+
24
28
  <!-- @windy-module system.license end -->
29
+
25
30
  - `audit-logs`:审计日志查询。
26
31
 
27
32
  ## 路由
@@ -34,6 +39,7 @@ GET /api/system/:resource/export
34
39
  GET /api/system/:resource/:id
35
40
  POST /api/system/:resource
36
41
  POST /api/system/departments/quick
42
+ POST /api/system/departments/:id/transition
37
43
  POST /api/system/:resource/import
38
44
  PUT /api/system/:resource/:id
39
45
  DELETE /api/system/:resource/:id
@@ -68,12 +74,17 @@ GET /api/system/audit-logs
68
74
 
69
75
  - 读操作:`system.<resource>.read`。
70
76
  - 用户表单快捷创建部门:`system.department.create`;既有通用部门创建仍要求 `system.department.manage`。
77
+ - 部门迁移后停用或删除:`system.department.manage`;接口绑定 `system.rbac` Feature 并记录 `organization.department.transition`。
71
78
  - 写操作:`system.<resource>.manage`。
72
79
  - 审计日志:`system.audit.read`。
73
80
  - 系统资源默认绑定 `system.rbac` Feature,审计日志绑定 `system.audit`。
81
+
74
82
  <!-- @windy-module system.license begin -->
83
+
75
84
  - License 资源绑定 `system.license`。
85
+
76
86
  <!-- @windy-module system.license end -->
87
+
77
88
  - Guard 固定按 Entitlement、动态 Feature Flag、RBAC 顺序判定。Feature 声明了 `requiredEntitlement` 但 License 快照缺失该能力、开关关闭/隐藏或用户缺少权限时,路由返回带稳定 `reason` 的 `403`,并记录 `security.denied`。
78
89
 
79
90
  配置 `DATABASE_URL` 后,Admin Web 使用 `/api/auth/login` 签发的 `windy.sid` HttpOnly Cookie;明确的非浏览器集成使用 `/api/auth/api/login` 独立签发的 Bearer Token。Server 只用 Token hash 查询未过期、未吊销的服务端会话,再从数据库活跃用户和角色聚合权限。开发 Token 必须显式配置,且 `NODE_ENV=production` 时强制关闭;完整说明见 [客户业务平台认证与会话](./platform-authentication.md)。
@@ -86,12 +97,17 @@ GET /api/system/audit-logs
86
97
  - Feature Guard 在配置数据库时按请求、按 Key 读取运行态;同一请求缓存结果,下一请求重新读取。开发无数据库时回退 Manifest,生产无数据库或读取失败时 fail closed。
87
98
  - CRUD 成功和权限拒绝会先进入进程内审计 sink;配置 `DATABASE_URL` 后由可靠写入器写入 PostgreSQL `audit_logs`。普通事件持续失败时业务继续并告警;用户、角色、License 与 Feature Flag 等关键变更必须落库或进入本地原子恢复队列。策略、状态和恢复步骤见 [平台审计可靠写入](./audit-reliability.md)。
88
99
  - 密码登录、服务端会话、退出和吊销已闭环;通用用户 CRUD 不读写或返回密码 hash,密码更新走受权限保护的认证 API。
100
+
89
101
  <!-- @windy-module system.license begin -->
102
+
90
103
  - 正式运行时 License 通过 `/api/license/runtime` 查看,并通过 `/api/license/runtime/install` 复用 SDK 验签和单槽原子替换;通用 `legacy_licenses` CRUD 只保留为显式原型兼容面,不能提供生产授权。
91
104
  - License 限制模式只对显式列举的系统资源 JSON 导出放行,导出仍需通过原有 Feature 与 RBAC Guard;其它 API、Agent Tool 和后台任务先由 License Guard 阻断。
105
+
92
106
  <!-- @windy-module system.license end -->
107
+
93
108
  - `feature_flags` 的 Drizzle 路径已补齐完整审计字段和软删除列;`0005` migration 会回填既有数据的 `created_by` 后再设置非空约束。
94
109
  - 通用系统资源仍保留同步 JSON 文件能力;字典已额外接入后台 NDJSON 任务、本人进度查询、失败明细和结果留存,Web 字典页默认使用后台工作流。其它实体尚未注册批量处理器。
110
+ - 用户、角色和部门禁止通用 JSON 导入,避免绕过账号、数据范围和组织树强不变量;部门备份导出仍可用于现场核查。
95
111
 
96
112
  ## 已完成补强与后续边界
97
113
 
@@ -9,7 +9,7 @@ Web 端已从单页静态首页推进为带真实会话的 Router 应用壳:
9
9
  - 侧边栏菜单可点击跳转,并按当前路由高亮。
10
10
  - 顶部面包屑读取 route meta 标题。
11
11
  - 顶部账号菜单展示当前 Actor 并提供服务端退出;401 会全局清理会话并返回登录页。
12
- - 顶部通知入口读取服务端通知收件箱,可打开正文并跨设备保存当前用户已读回执;无 `system.notification.read` 权限或 Feature 关闭时不显示入口,也不会请求通知 API
12
+ - 顶部通知入口读取服务端通知收件箱,可打开正文并跨设备保存当前用户已读与个人收藏回执;无 `system.notification.read` 权限或 Feature 关闭时不显示入口,也不会请求通知 API。后台通知管理页负责发布、修改、查看不可变历史和归档,普通用户不能修改公告。
13
13
 
14
14
  ## 页面范围
15
15
 
@@ -59,8 +59,12 @@ POST /api/system/bulk-data/dictionaries/import
59
59
  POST /api/system/bulk-data/dictionaries/export
60
60
  GET /api/notifications
61
61
  POST /api/notifications/:id/read
62
+ PUT /api/notifications/:id/favorite
63
+ DELETE /api/notifications/:id/favorite
62
64
  GET /api/system/notifications
63
65
  POST /api/system/notifications
66
+ PUT /api/system/notifications/:id
67
+ GET /api/system/notifications/:id/revisions
64
68
  DELETE /api/system/notifications/:id
65
69
  ```
66
70
 
@@ -84,7 +88,7 @@ VITE_DEV_ADMIN_TOKEN=<显式开发 Token>
84
88
  - Feature 开关成功修改会写入 `feature.update`,可在审计日志页通过“功能开关变更”直接查看。
85
89
  - 系统资源表和字典后台任务表加载时保留表头与列宽,并显示带 `animate-pulse` 的骨架行;加载失败和空数据仍使用各自独立状态。
86
90
  - 全局水印与业务页面范围默认开启。管理员在“全局设置”中维护总开关、低/中/高密度,并勾选用户名、姓名、手机号、邮箱或最多 80 个字符的自定义内容;保存写入审计并立即成为所有用户的只读策略。客户端按旋转文本包围盒自动计算平铺尺寸与安全间距,长内容和高密度模式也不会相互重叠。用户应用端不提供开关,也不使用 localStorage。`system.watermark` Feature 控制最终生效态,管理区另需 `system.watermark.read/manage` 权限。水印使用专用审计顶层覆盖 Sidebar、Drawer、Overlay 和 Modal,同时保持 `pointer-events-none`。服务端只向当前用户自己的会话响应补充这些身份字段。
87
- - 站内通知由 PostgreSQL `platform_notifications` 和 `notification_reads` 持久化;管理人员可在 `/system/notifications` 广播发布和归档,用户从 Header 收件箱阅读正文并幂等写入已读回执。归档是生命周期操作,不物理删除通知或回执。当前发布范围是全体有阅读权限的用户,尚未提供组织、角色、用户定向和外部渠道投递。
91
+ - 站内通知由 PostgreSQL `platform_notifications`、`notification_revisions`、`notification_reads` 和 `notification_favorites` 持久化;管理人员可在 `/system/notifications` 广播发布、修改、查看版本历史和归档,用户从 Header 收件箱阅读正文并幂等写入相互独立的已读、收藏回执。修改会生成带服务端时间和 Actor 的完整不可变快照,无内容变化不增加版本。普通用户 API 支持关键词、分类、已读和收藏的服务端组合筛选,列表与 `total` 使用同一可见性范围。归档是生命周期操作,不物理删除通知、版本或回执。
88
92
  - 字典页已切换为后台 NDJSON 导入导出,展示本人任务状态、进度、重试和终态文件下载;其它系统资源仍保留小数据同步 JSON 入口。详细契约见 [批量导入导出后台任务](./bulk-data-jobs.md)。当前 artifact 存在 10 MiB、50,000 行和 256 KiB 单行上限,扩大容量前需要对象存储 Adapter。
89
93
  - 审计日志配置 `DATABASE_URL` 后读取 PostgreSQL `audit_logs`。可靠写入器提供有界重试、降级状态、关键事件本地恢复队列和健康信息;页面仍只读,且不会展示恢复队列中的事件正文。
90
94
  - 运维页的定时任务来自真实代码注册和 PostgreSQL 状态,不允许在线编辑任务代码。当前基线的 `stale` 只做观察告警和人工处置提示,不会自动终止或重试。
@@ -16,6 +16,8 @@
16
16
  "dev:web": "bun run --cwd apps/web dev",
17
17
  "dev:server": "bun run --cwd apps/server dev",
18
18
  "dev:agent": "docker compose --profile agent up --build",
19
+ "dev:adk-web": "docker compose --profile agent-debug up --build adk-web-debug",
20
+ "dev:search": "OPENSEARCH_URL=http://opensearch:9200 docker compose --profile search up --build",
19
21
  "dev:license": "bun run --cwd apps/license dev",
20
22
  "dev:license:docker": "docker compose --profile license up license",
21
23
  "dev:signing": "bun run --cwd apps/signing-service dev",
@@ -28,6 +30,8 @@
28
30
  "db:migrate": "drizzle-kit migrate --config drizzle.config.ts",
29
31
  "test:db:auth": "bun run scripts/auth-postgres-regression.ts",
30
32
  "test:db:data-scope": "bun run scripts/data-scope-postgres-regression.ts",
33
+ "test:db:organization": "bun run scripts/organization-postgres-regression.ts",
34
+ "test:db:notification": "bun run scripts/notification-postgres-regression.ts",
31
35
  "test:db:governance": "bun run scripts/governance-postgres-regression.ts",
32
36
  "test:db:legacy-license": "bun run scripts/legacy-license-migration-regression.ts",
33
37
  "test:db:license-bootstrap": "bun run scripts/license-bootstrap-postgres-regression.ts",
@@ -29,6 +29,51 @@ test("平台 Redis 与 RuoYi 旧 Redis 使用独立 SecretResolver 配置", () =
29
29
  );
30
30
  });
31
31
 
32
+ test("平台设置提供类型化默认值并校验 IANA 时区", () => {
33
+ const config = loadPlatformConfig({});
34
+
35
+ expect(config.security).toMatchObject({
36
+ sessionTtlSeconds: 28_800,
37
+ passwordMinLength: 12,
38
+ passwordMaxLength: 128,
39
+ });
40
+ expect(config.storage).toEqual({
41
+ bulkDataMaxByteSize: 512 * 1024 * 1024,
42
+ brandingLogoMaxByteSize: 5 * 1024 * 1024,
43
+ });
44
+ expect(config.mail).toMatchObject({
45
+ enabled: false,
46
+ port: 587,
47
+ secure: false,
48
+ });
49
+ expect(config.regional.timeZone).toBe("Asia/Shanghai");
50
+ expect(() =>
51
+ loadPlatformConfig({ REGIONAL_TIME_ZONE: "Invalid/Timezone" }),
52
+ ).toThrow(
53
+ expect.objectContaining<Partial<ConfigLoadError>>({
54
+ code: "CONFIG_INVALID_VALUE",
55
+ fieldPath: "regional.timeZone",
56
+ }),
57
+ );
58
+ expect(() =>
59
+ loadPlatformConfig({
60
+ PASSWORD_MIN_LENGTH: "64",
61
+ PASSWORD_MAX_LENGTH: "32",
62
+ }),
63
+ ).toThrow(
64
+ expect.objectContaining<Partial<ConfigLoadError>>({
65
+ code: "CONFIG_INVALID_VALUE",
66
+ fieldPath: "security.passwordMinLength",
67
+ }),
68
+ );
69
+ expect(() => loadPlatformConfig({ MAIL_ENABLED: "true" })).toThrow(
70
+ expect.objectContaining<Partial<ConfigLoadError>>({
71
+ code: "CONFIG_INVALID_VALUE",
72
+ fieldPath: "mail.fromAddress",
73
+ }),
74
+ );
75
+ });
76
+
32
77
  test("strict loader 对 unknown 与错误类型 fail-fast 且不回显输入值", () => {
33
78
  expect(() => loadPlatformConfig({ PLATFORM_ENABLE_AII: "true" })).toThrow(
34
79
  expect.objectContaining<Partial<ConfigLoadError>>({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@southwind-ai/config",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,7 +34,23 @@ export interface PlatformConfig {
34
34
  sessionCookieName: string;
35
35
  sessionTtlSeconds: number;
36
36
  passwordHash: "argon2id";
37
+ passwordMinLength: number;
38
+ passwordMaxLength: number;
37
39
  };
40
+ storage: {
41
+ bulkDataMaxByteSize: number;
42
+ brandingLogoMaxByteSize: number;
43
+ };
44
+ mail: {
45
+ enabled: boolean;
46
+ host: string;
47
+ port: number;
48
+ secure: boolean;
49
+ fromAddress: string;
50
+ connectTimeoutMs: number;
51
+ smtpUrl?: string;
52
+ };
53
+ regional: { timeZone: string };
38
54
  license: {
39
55
  enabled: boolean;
40
56
  publicKeyId?: string;
@@ -60,56 +76,111 @@ export interface ProjectConfig {
60
76
  modules: string[];
61
77
  }
62
78
 
63
- const platformSchema: z.ZodType<PlatformConfig> = z.strictObject({
64
- name: z.string().trim().min(1).max(120),
65
- logoUrl: z.string().trim().url().optional(),
66
- enableAI: z.boolean(),
67
- database: z.strictObject({
68
- dialect: z.enum(["postgresql", "mysql", "dm", "kingbase"]),
69
- url: z.string().trim().min(1).optional(),
70
- legacyRuoYiMysqlUrl: z.string().trim().min(1).optional(),
71
- legacyRuoYiRedisUrl: z.string().trim().min(1).optional(),
72
- }),
73
- cache: z.strictObject({
74
- redisUrl: z.string().trim().min(1).optional(),
75
- healthTimeoutMs: z.number().int().min(50).max(10_000),
76
- }),
77
- security: z.strictObject({
78
- sessionCookieName: z.string().trim().min(1).max(120),
79
- sessionTtlSeconds: z.number().int().min(60).max(2_592_000),
80
- passwordHash: z.literal("argon2id"),
81
- }),
82
- license: z.strictObject({
83
- enabled: z.boolean(),
84
- publicKeyId: z.string().trim().min(1).optional(),
85
- publicKey: z.string().trim().min(1).optional(),
86
- machineCode: z.string().trim().min(1).optional(),
87
- versions: z
88
- .unknown()
89
- .transform((value, context) => {
90
- try {
91
- return parseLicenseCatalog(value);
92
- } catch (error) {
93
- context.addIssue({
94
- code: "custom",
95
- message: error instanceof Error ? error.message : "版本目录无效",
96
- });
97
- return z.NEVER;
98
- }
99
- })
100
- .optional(),
101
- }),
102
- audit: z.strictObject({
103
- enabled: z.boolean(),
104
- redactFields: z.array(z.string().min(1)).min(1),
105
- }),
106
- compatibility: z.strictObject({
107
- ruoyiVue: z.strictObject({
79
+ const platformSchema: z.ZodType<PlatformConfig> = z
80
+ .strictObject({
81
+ name: z.string().trim().min(1).max(120),
82
+ logoUrl: z.string().trim().url().optional(),
83
+ enableAI: z.boolean(),
84
+ database: z.strictObject({
85
+ dialect: z.enum(["postgresql", "mysql", "dm", "kingbase"]),
86
+ url: z.string().trim().min(1).optional(),
87
+ legacyRuoYiMysqlUrl: z.string().trim().min(1).optional(),
88
+ legacyRuoYiRedisUrl: z.string().trim().min(1).optional(),
89
+ }),
90
+ cache: z.strictObject({
91
+ redisUrl: z.string().trim().min(1).optional(),
92
+ healthTimeoutMs: z.number().int().min(50).max(10_000),
93
+ }),
94
+ security: z.strictObject({
95
+ sessionCookieName: z.string().trim().min(1).max(120),
96
+ sessionTtlSeconds: z.number().int().min(60).max(2_592_000),
97
+ passwordHash: z.literal("argon2id"),
98
+ passwordMinLength: z.number().int().min(8).max(128),
99
+ passwordMaxLength: z.number().int().min(12).max(512),
100
+ }),
101
+ storage: z.strictObject({
102
+ bulkDataMaxByteSize: z
103
+ .number()
104
+ .int()
105
+ .min(1024)
106
+ .max(10 * 1024 ** 3),
107
+ brandingLogoMaxByteSize: z
108
+ .number()
109
+ .int()
110
+ .min(1024)
111
+ .max(50 * 1024 ** 2),
112
+ }),
113
+ mail: z.strictObject({
108
114
  enabled: z.boolean(),
109
- runtimeReadonly: z.boolean(),
115
+ host: z.string().trim().max(253),
116
+ port: z.number().int().min(1).max(65_535),
117
+ secure: z.boolean(),
118
+ fromAddress: z.string().trim().max(320),
119
+ connectTimeoutMs: z.number().int().min(100).max(120_000),
120
+ smtpUrl: z.string().trim().min(1).optional(),
110
121
  }),
111
- }),
112
- });
122
+ regional: z.strictObject({
123
+ timeZone: z
124
+ .string()
125
+ .trim()
126
+ .min(1)
127
+ .max(100)
128
+ .refine(isSupportedTimeZone, "时区必须是有效的 IANA 时区"),
129
+ }),
130
+ license: z.strictObject({
131
+ enabled: z.boolean(),
132
+ publicKeyId: z.string().trim().min(1).optional(),
133
+ publicKey: z.string().trim().min(1).optional(),
134
+ machineCode: z.string().trim().min(1).optional(),
135
+ versions: z
136
+ .unknown()
137
+ .transform((value, context) => {
138
+ try {
139
+ return parseLicenseCatalog(value);
140
+ } catch (error) {
141
+ context.addIssue({
142
+ code: "custom",
143
+ message: error instanceof Error ? error.message : "版本目录无效",
144
+ });
145
+ return z.NEVER;
146
+ }
147
+ })
148
+ .optional(),
149
+ }),
150
+ audit: z.strictObject({
151
+ enabled: z.boolean(),
152
+ redactFields: z.array(z.string().min(1)).min(1),
153
+ }),
154
+ compatibility: z.strictObject({
155
+ ruoyiVue: z.strictObject({
156
+ enabled: z.boolean(),
157
+ runtimeReadonly: z.boolean(),
158
+ }),
159
+ }),
160
+ })
161
+ .superRefine((config, context) => {
162
+ if (config.security.passwordMinLength > config.security.passwordMaxLength) {
163
+ context.addIssue({
164
+ code: "custom",
165
+ path: ["security", "passwordMinLength"],
166
+ message: "密码最小长度不能大于最大长度",
167
+ });
168
+ }
169
+ if (config.mail.enabled && !config.mail.fromAddress) {
170
+ context.addIssue({
171
+ code: "custom",
172
+ path: ["mail", "fromAddress"],
173
+ message: "启用邮件传输时必须配置发件地址",
174
+ });
175
+ }
176
+ if (config.mail.enabled && !config.mail.smtpUrl && !config.mail.host) {
177
+ context.addIssue({
178
+ code: "custom",
179
+ path: ["mail", "host"],
180
+ message: "启用邮件传输时必须配置 SMTP URL 或主机",
181
+ });
182
+ }
183
+ });
113
184
 
114
185
  export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
115
186
  key: "platform.runtime",
@@ -118,9 +189,13 @@ export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
118
189
  ownedEnvironmentPrefixes: [
119
190
  "PLATFORM_",
120
191
  "DATABASE_",
192
+ "PASSWORD_",
121
193
  "REDIS_",
122
194
  "RUOYI_",
123
195
  "SESSION_",
196
+ "STORAGE_",
197
+ "MAIL_",
198
+ "REGIONAL_",
124
199
  "AUDIT_",
125
200
  ],
126
201
  ownedEnvironmentKeys: [
@@ -153,6 +228,43 @@ export const platformConfigDefinition = defineModuleConfig<PlatformConfig>({
153
228
  strictInteger,
154
229
  ),
155
230
  value("PLATFORM_PASSWORD_HASH", "security.passwordHash", "argon2id"),
231
+ value(
232
+ "PASSWORD_MIN_LENGTH",
233
+ "security.passwordMinLength",
234
+ 12,
235
+ strictInteger,
236
+ ),
237
+ value(
238
+ "PASSWORD_MAX_LENGTH",
239
+ "security.passwordMaxLength",
240
+ 128,
241
+ strictInteger,
242
+ ),
243
+ value(
244
+ "STORAGE_BULK_DATA_MAX_BYTE_SIZE",
245
+ "storage.bulkDataMaxByteSize",
246
+ 512 * 1024 * 1024,
247
+ strictInteger,
248
+ ),
249
+ value(
250
+ "STORAGE_BRANDING_LOGO_MAX_BYTE_SIZE",
251
+ "storage.brandingLogoMaxByteSize",
252
+ 5 * 1024 * 1024,
253
+ strictInteger,
254
+ ),
255
+ value("MAIL_ENABLED", "mail.enabled", false, strictBoolean),
256
+ value("MAIL_HOST", "mail.host", ""),
257
+ value("MAIL_PORT", "mail.port", 587, strictInteger),
258
+ value("MAIL_SECURE", "mail.secure", false, strictBoolean),
259
+ value("MAIL_FROM_ADDRESS", "mail.fromAddress", ""),
260
+ value(
261
+ "MAIL_CONNECT_TIMEOUT_MS",
262
+ "mail.connectTimeoutMs",
263
+ 10_000,
264
+ strictInteger,
265
+ ),
266
+ secret("MAIL_SMTP_URL", "mail.smtpUrl"),
267
+ value("REGIONAL_TIME_ZONE", "regional.timeZone", "Asia/Shanghai"),
156
268
  value("LICENSE_ENABLED", "license.enabled", true, strictBoolean),
157
269
  value("LICENSE_PUBLIC_KEY_ID", "license.publicKeyId"),
158
270
  value("LICENSE_PUBLIC_KEY", "license.publicKey"),
@@ -210,3 +322,12 @@ function value(
210
322
  function secret(key: string, path: string) {
211
323
  return { key, path, secret: { provider: "env" as const } };
212
324
  }
325
+
326
+ function isSupportedTimeZone(value: string): boolean {
327
+ try {
328
+ new Intl.DateTimeFormat("zh-CN", { timeZone: value });
329
+ return true;
330
+ } catch {
331
+ return false;
332
+ }
333
+ }
@@ -0,0 +1,10 @@
1
+ CREATE TABLE "notification_favorites" (
2
+ "notification_id" varchar(64) NOT NULL,
3
+ "user_id" varchar(64) NOT NULL,
4
+ "favorited_at" timestamp with time zone DEFAULT now() NOT NULL,
5
+ CONSTRAINT "notification_favorites_notification_id_user_id_pk" PRIMARY KEY("notification_id","user_id")
6
+ );
7
+ --> statement-breakpoint
8
+ ALTER TABLE "notification_favorites" ADD CONSTRAINT "notification_favorites_notification_id_platform_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."platform_notifications"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
9
+ ALTER TABLE "notification_favorites" ADD CONSTRAINT "notification_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
10
+ CREATE INDEX "idx_notification_favorites_user" ON "notification_favorites" USING btree ("user_id","favorited_at");
@@ -0,0 +1,35 @@
1
+ CREATE TABLE "notification_revisions" (
2
+ "notification_id" varchar(64) NOT NULL,
3
+ "revision" integer NOT NULL,
4
+ "title" varchar(120) NOT NULL,
5
+ "summary" varchar(240) NOT NULL,
6
+ "content" text NOT NULL,
7
+ "category" varchar(40) NOT NULL,
8
+ "edited_at" timestamp with time zone NOT NULL,
9
+ "edited_by" varchar(64) NOT NULL,
10
+ CONSTRAINT "notification_revisions_notification_id_revision_pk" PRIMARY KEY("notification_id","revision")
11
+ );
12
+ --> statement-breakpoint
13
+ ALTER TABLE "platform_notifications" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
14
+ ALTER TABLE "notification_revisions" ADD CONSTRAINT "notification_revisions_notification_id_platform_notifications_id_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."platform_notifications"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
15
+ CREATE INDEX "idx_notification_revisions_time" ON "notification_revisions" USING btree ("notification_id","edited_at");--> statement-breakpoint
16
+ INSERT INTO "notification_revisions" (
17
+ "notification_id",
18
+ "revision",
19
+ "title",
20
+ "summary",
21
+ "content",
22
+ "category",
23
+ "edited_at",
24
+ "edited_by"
25
+ )
26
+ SELECT
27
+ "id",
28
+ 1,
29
+ "title",
30
+ "summary",
31
+ "content",
32
+ "category",
33
+ "published_at",
34
+ "created_by"
35
+ FROM "platform_notifications";
@@ -0,0 +1,2 @@
1
+ CREATE UNIQUE INDEX "uk_departments_single_root" ON "departments" USING btree (((1))) WHERE "departments"."parent_id" is null and "departments"."deleted_at" is null;--> statement-breakpoint
2
+ ALTER TABLE "departments" ADD CONSTRAINT "departments_parent_not_self" CHECK ("departments"."parent_id" is null or "departments"."parent_id" <> "departments"."id");