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
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@southwind-ai/server-sdk";
7
7
  import { evaluateAccessGuards } from "../access-guards.js";
8
8
  import { isServerScopeContext } from "../data-access/context.js";
9
- import type { RequestGuardContext } from "../guards.js";
9
+ import { auditActor, type RequestGuardContext } from "../guards.js";
10
10
  import type { createServerRuntime } from "../runtime.js";
11
11
  import { findFeature } from "../route-guards.js";
12
12
  import type { ModuleStorageScope } from "./storage-port.js";
@@ -73,14 +73,7 @@ function resourceFileEvent(
73
73
  id: randomUUIDv7(),
74
74
  action: policy.auditAction,
75
75
  outcome,
76
- actor: {
77
- id: input.guardContext.actor.id,
78
- type:
79
- input.guardContext.actor.type === "anonymous"
80
- ? ("user" as const)
81
- : input.guardContext.actor.type,
82
- name: input.guardContext.actor.name,
83
- },
76
+ actor: auditActor(input.guardContext),
84
77
  target: {
85
78
  type: request.resourceType,
86
79
  id: request.resourceId,
@@ -8,7 +8,7 @@ import {
8
8
  import type { AuditEvent, FeatureFlagDefinition } from "@southwind-ai/shared";
9
9
  import { DataScopedResourceService } from "../data-access/scoped-resource.js";
10
10
  import { departmentDescendantResolver } from "../data-access/department-tree.js";
11
- import type { RequestGuardContext } from "../guards.js";
11
+ import { auditActor, type RequestGuardContext } from "../guards.js";
12
12
  import { evaluateRouteGuards, findFeature } from "../route-guards.js";
13
13
  import type { createServerRuntime } from "../runtime.js";
14
14
  import type { SystemRepositories } from "../system/seed.js";
@@ -125,11 +125,7 @@ function queryAuditEvent(
125
125
  id: randomUUIDv7(),
126
126
  action: auditAction,
127
127
  outcome: "success",
128
- actor: {
129
- id: context.actor.id,
130
- type: context.actor.type === "anonymous" ? "user" : context.actor.type,
131
- name: context.actor.name,
132
- },
128
+ actor: auditActor(context),
133
129
  target: { type: "system.user-directory", id: "enabled-users" },
134
130
  requestId: context.requestId,
135
131
  metadata: {
@@ -0,0 +1,46 @@
1
+ import type { ExternalNotificationChannel } from "@southwind-ai/shared";
2
+
3
+ export interface NotificationChannelMessage {
4
+ deliveryId: string;
5
+ userId: string;
6
+ address: string;
7
+ subject: string;
8
+ content: string;
9
+ }
10
+
11
+ export interface NotificationChannelResult {
12
+ providerMessageId?: string;
13
+ }
14
+
15
+ export interface NotificationChannelAdapter {
16
+ readonly channel: ExternalNotificationChannel;
17
+ deliver(
18
+ message: NotificationChannelMessage,
19
+ ): Promise<NotificationChannelResult>;
20
+ }
21
+
22
+ export class NotificationChannelError extends Error {
23
+ constructor(
24
+ readonly code: string,
25
+ readonly retryable: boolean,
26
+ message = code,
27
+ ) {
28
+ super(message);
29
+ this.name = "NotificationChannelError";
30
+ }
31
+ }
32
+
33
+ export class NotificationChannelRegistry {
34
+ private readonly adapters = new Map<
35
+ ExternalNotificationChannel,
36
+ NotificationChannelAdapter
37
+ >();
38
+
39
+ constructor(adapters: NotificationChannelAdapter[] = []) {
40
+ for (const adapter of adapters) this.adapters.set(adapter.channel, adapter);
41
+ }
42
+
43
+ get(channel: ExternalNotificationChannel) {
44
+ return this.adapters.get(channel);
45
+ }
46
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mergeExternalDeliveryPolicy } from "./delivery-policy.js";
3
+
4
+ describe("mergeExternalDeliveryPolicy", () => {
5
+ test("未设置偏好时,普通类别只保留站内通知", () => {
6
+ expect(
7
+ mergeExternalDeliveryPolicy({
8
+ category: "运营",
9
+ requestedChannels: ["email", "sms"],
10
+ mandatoryCategories: new Set(),
11
+ }),
12
+ ).toEqual([]);
13
+ });
14
+
15
+ test("普通类别先应用类别退订,再取请求渠道与启用渠道交集", () => {
16
+ const preference = {
17
+ userId: "u1",
18
+ enabledChannels: ["email"] as const,
19
+ disabledCategories: ["营销"],
20
+ };
21
+ expect(
22
+ mergeExternalDeliveryPolicy({
23
+ category: "营销",
24
+ requestedChannels: ["email"],
25
+ preference: {
26
+ ...preference,
27
+ enabledChannels: [...preference.enabledChannels],
28
+ },
29
+ mandatoryCategories: new Set(),
30
+ }),
31
+ ).toEqual([]);
32
+ expect(
33
+ mergeExternalDeliveryPolicy({
34
+ category: "运营",
35
+ requestedChannels: ["email", "sms"],
36
+ preference: {
37
+ ...preference,
38
+ enabledChannels: [...preference.enabledChannels],
39
+ },
40
+ mandatoryCategories: new Set(),
41
+ }),
42
+ ).toEqual(["email"]);
43
+ });
44
+
45
+ test("强制类别绕过用户外部偏好,但不增加未请求渠道", () => {
46
+ expect(
47
+ mergeExternalDeliveryPolicy({
48
+ category: "安全",
49
+ requestedChannels: ["sms", "sms"],
50
+ preference: {
51
+ userId: "u1",
52
+ enabledChannels: [],
53
+ disabledCategories: ["安全"],
54
+ },
55
+ mandatoryCategories: new Set(["安全"]),
56
+ }),
57
+ ).toEqual(["sms"]);
58
+ });
59
+ });
@@ -0,0 +1,37 @@
1
+ import type {
2
+ ExternalNotificationChannel,
3
+ NotificationPreference,
4
+ } from "@southwind-ai/shared";
5
+
6
+ export interface NotificationDeliveryPolicyInput {
7
+ category: string;
8
+ requestedChannels: ExternalNotificationChannel[];
9
+ preference?: NotificationPreference;
10
+ mandatoryCategories: ReadonlySet<string>;
11
+ }
12
+
13
+ /**
14
+ * 站内通知始终可见,此函数只决定外部渠道:
15
+ * 强制类别 > 类别退订 > 渠道启用;未保存偏好时外部渠道默认关闭。
16
+ */
17
+ export function mergeExternalDeliveryPolicy(
18
+ input: NotificationDeliveryPolicyInput,
19
+ ): ExternalNotificationChannel[] {
20
+ if (input.mandatoryCategories.has(input.category)) {
21
+ return unique(input.requestedChannels);
22
+ }
23
+ if (
24
+ !input.preference ||
25
+ input.preference.disabledCategories.includes(input.category)
26
+ ) {
27
+ return [];
28
+ }
29
+ const enabled = new Set(input.preference.enabledChannels);
30
+ return unique(input.requestedChannels).filter((channel) =>
31
+ enabled.has(channel),
32
+ );
33
+ }
34
+
35
+ function unique<T>(values: T[]) {
36
+ return [...new Set(values)];
37
+ }
@@ -0,0 +1,198 @@
1
+ import { notificationDeliveryOutbox } from "@southwind-ai/database";
2
+ import type {
3
+ ExternalNotificationChannel,
4
+ NotificationDelivery,
5
+ } from "@southwind-ai/shared";
6
+ import { and, asc, eq, inArray, sql } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+
9
+ export interface NotificationDeliveryDraft {
10
+ notificationId: string;
11
+ userId: string;
12
+ channel: ExternalNotificationChannel;
13
+ subject: string;
14
+ content: string;
15
+ }
16
+
17
+ export interface ClaimedNotificationDelivery extends NotificationDeliveryDraft {
18
+ id: string;
19
+ attemptCount: number;
20
+ leaseToken: string;
21
+ }
22
+
23
+ export interface NotificationDeliveryRepository {
24
+ enqueue(drafts: NotificationDeliveryDraft[]): Promise<number>;
25
+ claim(now: Date, limit: number): Promise<ClaimedNotificationDelivery[]>;
26
+ complete(
27
+ id: string,
28
+ leaseToken: string,
29
+ providerMessageId?: string,
30
+ ): Promise<boolean>;
31
+ fail(
32
+ id: string,
33
+ leaseToken: string,
34
+ code: string,
35
+ retryAt?: Date,
36
+ ): Promise<boolean>;
37
+ list(notificationId: string): Promise<NotificationDelivery[]>;
38
+ }
39
+
40
+ export class DrizzleNotificationDeliveryRepository implements NotificationDeliveryRepository {
41
+ constructor(
42
+ private readonly db: NodePgDatabase,
43
+ private readonly leaseMs = 30_000,
44
+ ) {}
45
+
46
+ async enqueue(drafts: NotificationDeliveryDraft[]) {
47
+ if (drafts.length === 0) return 0;
48
+ const now = new Date();
49
+ return (
50
+ await this.db
51
+ .insert(notificationDeliveryOutbox)
52
+ .values(
53
+ drafts.map((draft) => ({
54
+ id: Bun.randomUUIDv7(),
55
+ ...draft,
56
+ status: "pending" as const,
57
+ nextAttemptAt: now,
58
+ createdAt: now,
59
+ updatedAt: now,
60
+ })),
61
+ )
62
+ .onConflictDoNothing()
63
+ .returning({ id: notificationDeliveryOutbox.id })
64
+ ).length;
65
+ }
66
+
67
+ async claim(now: Date, limit: number) {
68
+ const leaseToken = Bun.randomUUIDv7();
69
+ const leaseExpiresAt = new Date(now.getTime() + this.leaseMs);
70
+ return this.db.transaction(async (tx) => {
71
+ const rows = await tx.execute<{
72
+ id: string;
73
+ notification_id: string;
74
+ user_id: string;
75
+ channel: ExternalNotificationChannel;
76
+ subject: string;
77
+ content: string;
78
+ attempt_count: number;
79
+ }>(sql`
80
+ select id, notification_id, user_id, channel, subject, content,
81
+ attempt_count
82
+ from notification_delivery_outbox
83
+ where (
84
+ status in ('pending', 'retrying')
85
+ and next_attempt_at <= ${now}
86
+ ) or (
87
+ status = 'processing'
88
+ and lease_expires_at <= ${now}
89
+ )
90
+ order by next_attempt_at asc
91
+ limit ${limit}
92
+ for update skip locked
93
+ `);
94
+ const claimed = rows.rows;
95
+ if (claimed.length === 0) return [];
96
+ await tx
97
+ .update(notificationDeliveryOutbox)
98
+ .set({
99
+ status: "processing",
100
+ leaseToken,
101
+ leaseExpiresAt,
102
+ updatedAt: now,
103
+ attemptCount: sql`${notificationDeliveryOutbox.attemptCount} + 1`,
104
+ })
105
+ .where(
106
+ inArray(
107
+ notificationDeliveryOutbox.id,
108
+ claimed.map((row) => row.id),
109
+ ),
110
+ );
111
+ return claimed.map((row) => ({
112
+ id: row.id,
113
+ notificationId: row.notification_id,
114
+ userId: row.user_id,
115
+ channel: row.channel,
116
+ subject: row.subject,
117
+ content: row.content,
118
+ attemptCount: row.attempt_count + 1,
119
+ leaseToken,
120
+ }));
121
+ });
122
+ }
123
+
124
+ async complete(id: string, leaseToken: string, providerMessageId?: string) {
125
+ const now = new Date();
126
+ return Boolean(
127
+ (
128
+ await this.db
129
+ .update(notificationDeliveryOutbox)
130
+ .set({
131
+ status: "delivered",
132
+ deliveredAt: now,
133
+ providerMessageId,
134
+ leaseToken: null,
135
+ leaseExpiresAt: null,
136
+ lastErrorCode: null,
137
+ updatedAt: now,
138
+ })
139
+ .where(activeLease(id, leaseToken))
140
+ .returning({ id: notificationDeliveryOutbox.id })
141
+ )[0],
142
+ );
143
+ }
144
+
145
+ async fail(id: string, leaseToken: string, code: string, retryAt?: Date) {
146
+ return Boolean(
147
+ (
148
+ await this.db
149
+ .update(notificationDeliveryOutbox)
150
+ .set({
151
+ status: retryAt ? "retrying" : "dead",
152
+ nextAttemptAt: retryAt || new Date(),
153
+ leaseToken: null,
154
+ leaseExpiresAt: null,
155
+ lastErrorCode: code,
156
+ updatedAt: new Date(),
157
+ })
158
+ .where(activeLease(id, leaseToken))
159
+ .returning({ id: notificationDeliveryOutbox.id })
160
+ )[0],
161
+ );
162
+ }
163
+
164
+ async list(notificationId: string) {
165
+ const rows = await this.db
166
+ .select()
167
+ .from(notificationDeliveryOutbox)
168
+ .where(eq(notificationDeliveryOutbox.notificationId, notificationId))
169
+ .orderBy(asc(notificationDeliveryOutbox.createdAt));
170
+ return rows.map(toDelivery);
171
+ }
172
+ }
173
+
174
+ function activeLease(id: string, leaseToken: string) {
175
+ return and(
176
+ eq(notificationDeliveryOutbox.id, id),
177
+ eq(notificationDeliveryOutbox.status, "processing"),
178
+ eq(notificationDeliveryOutbox.leaseToken, leaseToken),
179
+ );
180
+ }
181
+
182
+ function toDelivery(
183
+ row: typeof notificationDeliveryOutbox.$inferSelect,
184
+ ): NotificationDelivery {
185
+ return {
186
+ id: row.id,
187
+ notificationId: row.notificationId,
188
+ userId: row.userId,
189
+ channel: row.channel,
190
+ status: row.status,
191
+ attemptCount: row.attemptCount,
192
+ nextAttemptAt: row.nextAttemptAt.toISOString(),
193
+ deliveredAt: row.deliveredAt?.toISOString(),
194
+ lastErrorCode: row.lastErrorCode || undefined,
195
+ createdAt: row.createdAt.toISOString(),
196
+ updatedAt: row.updatedAt.toISOString(),
197
+ };
198
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ NotificationChannelError,
4
+ NotificationChannelRegistry,
5
+ } from "./channel-adapter.js";
6
+ import { InMemoryNotificationDeliveryRepository } from "./memory-delivery-repository.js";
7
+ import { NotificationDeliveryWorker } from "./delivery-worker.js";
8
+ import type { NotificationRecipientDirectory } from "./recipient-directory.js";
9
+
10
+ const directory: NotificationRecipientDirectory = {
11
+ async options() {
12
+ return { users: [], roles: [], departments: [] };
13
+ },
14
+ async resolve() {
15
+ return [];
16
+ },
17
+ async address(_userId, channel) {
18
+ return channel === "email" ? "user@example.com" : "13800000000";
19
+ },
20
+ };
21
+
22
+ describe("NotificationDeliveryWorker", () => {
23
+ test("成功投递后完成 outbox", async () => {
24
+ const repository = new InMemoryNotificationDeliveryRepository();
25
+ await repository.enqueue([draft("email")]);
26
+ const worker = new NotificationDeliveryWorker(
27
+ repository,
28
+ directory,
29
+ new NotificationChannelRegistry([
30
+ {
31
+ channel: "email",
32
+ async deliver() {
33
+ return { providerMessageId: "smtp-1" };
34
+ },
35
+ },
36
+ ]),
37
+ );
38
+ expect(await worker.runNext(new Date("2030-01-01T00:00:00.000Z"))).toEqual({
39
+ claimed: 1,
40
+ delivered: 1,
41
+ retrying: 0,
42
+ dead: 0,
43
+ });
44
+ expect((await repository.list("n1"))[0]?.status).toBe("delivered");
45
+ });
46
+
47
+ test("可重试错误进入退避,未配置短信明确进入 dead", async () => {
48
+ const repository = new InMemoryNotificationDeliveryRepository();
49
+ await repository.enqueue([draft("email"), draft("sms")]);
50
+ const worker = new NotificationDeliveryWorker(
51
+ repository,
52
+ directory,
53
+ new NotificationChannelRegistry([
54
+ {
55
+ channel: "email",
56
+ async deliver() {
57
+ throw new NotificationChannelError("SMTP_TEMPORARY", true);
58
+ },
59
+ },
60
+ ]),
61
+ { retryBaseMs: 1_000 },
62
+ );
63
+ expect(await worker.runNext(new Date("2030-01-01T00:00:00.000Z"))).toEqual({
64
+ claimed: 2,
65
+ delivered: 0,
66
+ retrying: 1,
67
+ dead: 1,
68
+ });
69
+ const items = await repository.list("n1");
70
+ expect(items.find((item) => item.channel === "email")?.status).toBe(
71
+ "retrying",
72
+ );
73
+ expect(items.find((item) => item.channel === "sms")?.lastErrorCode).toBe(
74
+ "CHANNEL_NOT_CONFIGURED",
75
+ );
76
+ });
77
+ });
78
+
79
+ function draft(channel: "email" | "sms") {
80
+ return {
81
+ notificationId: "n1",
82
+ userId: "u1",
83
+ channel,
84
+ subject: "测试通知",
85
+ content: "正文",
86
+ };
87
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ NotificationChannelError,
3
+ NotificationChannelRegistry,
4
+ } from "./channel-adapter.js";
5
+ import type { NotificationDeliveryRepository } from "./delivery-repository.js";
6
+ import type { NotificationRecipientDirectory } from "./recipient-directory.js";
7
+
8
+ export interface NotificationDeliveryWorkerOptions {
9
+ batchSize?: number;
10
+ maxAttempts?: number;
11
+ retryBaseMs?: number;
12
+ }
13
+
14
+ export class NotificationDeliveryWorker {
15
+ private readonly batchSize: number;
16
+ private readonly maxAttempts: number;
17
+ private readonly retryBaseMs: number;
18
+
19
+ constructor(
20
+ private readonly repository: NotificationDeliveryRepository,
21
+ private readonly recipients: NotificationRecipientDirectory,
22
+ private readonly channels: NotificationChannelRegistry,
23
+ options: NotificationDeliveryWorkerOptions = {},
24
+ ) {
25
+ this.batchSize = options.batchSize || 20;
26
+ this.maxAttempts = options.maxAttempts || 5;
27
+ this.retryBaseMs = options.retryBaseMs || 5_000;
28
+ }
29
+
30
+ async runNext(now = new Date()) {
31
+ const deliveries = await this.repository.claim(now, this.batchSize);
32
+ let delivered = 0;
33
+ let retrying = 0;
34
+ let dead = 0;
35
+ for (const delivery of deliveries) {
36
+ const adapter = this.channels.get(delivery.channel);
37
+ const address = await this.recipients.address(
38
+ delivery.userId,
39
+ delivery.channel,
40
+ );
41
+ if (!adapter || !address) {
42
+ await this.repository.fail(
43
+ delivery.id,
44
+ delivery.leaseToken,
45
+ adapter ? "RECIPIENT_ADDRESS_MISSING" : "CHANNEL_NOT_CONFIGURED",
46
+ );
47
+ dead += 1;
48
+ continue;
49
+ }
50
+ try {
51
+ const result = await adapter.deliver({
52
+ deliveryId: delivery.id,
53
+ userId: delivery.userId,
54
+ address,
55
+ subject: delivery.subject,
56
+ content: delivery.content,
57
+ });
58
+ await this.repository.complete(
59
+ delivery.id,
60
+ delivery.leaseToken,
61
+ result.providerMessageId,
62
+ );
63
+ delivered += 1;
64
+ } catch (error) {
65
+ const channelError =
66
+ error instanceof NotificationChannelError
67
+ ? error
68
+ : new NotificationChannelError("CHANNEL_DELIVERY_FAILED", true);
69
+ const retry =
70
+ channelError.retryable && delivery.attemptCount < this.maxAttempts;
71
+ await this.repository.fail(
72
+ delivery.id,
73
+ delivery.leaseToken,
74
+ channelError.code,
75
+ retry ? this.retryAt(now, delivery.attemptCount) : undefined,
76
+ );
77
+ if (retry) retrying += 1;
78
+ else dead += 1;
79
+ }
80
+ }
81
+ return { claimed: deliveries.length, delivered, retrying, dead };
82
+ }
83
+
84
+ private retryAt(now: Date, attempt: number) {
85
+ const delay = this.retryBaseMs * 2 ** Math.max(0, attempt - 1);
86
+ return new Date(now.getTime() + Math.min(delay, 60 * 60 * 1_000));
87
+ }
88
+ }