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
@@ -0,0 +1,55 @@
1
+ import type { ManagedDepartment, ManagedRole } from "./entities.js";
2
+ import type { EntityRepository } from "./repository.js";
3
+ import type {
4
+ ResourcePolicyError,
5
+ ResourcePolicyResult,
6
+ } from "./route-types.js";
7
+
8
+ export class RoleDepartmentScopePolicy {
9
+ constructor(
10
+ private readonly departments: EntityRepository<ManagedDepartment>,
11
+ ) {}
12
+
13
+ async validate(
14
+ input: Partial<ManagedRole>,
15
+ current?: ManagedRole,
16
+ ): Promise<ResourcePolicyResult> {
17
+ const dataScope = input.dataScope ?? current?.dataScope;
18
+ const departmentIds = input.departmentIds ?? current?.departmentIds ?? [];
19
+ if (dataScope === "custom" && departmentIds.length === 0) {
20
+ return invalid(
21
+ "CUSTOM_DEPARTMENT_SCOPE_REQUIRED",
22
+ "自定义部门数据权限至少需要选择一个启用部门",
23
+ );
24
+ }
25
+ if (input.departmentIds === undefined && dataScope !== "custom") {
26
+ return undefined;
27
+ }
28
+ if (!Array.isArray(departmentIds) || departmentIds.some((id) => !id)) {
29
+ return invalid(
30
+ "INVALID_ROLE_DEPARTMENT_SCOPE",
31
+ "角色自定义部门范围必须是非空部门 ID 数组",
32
+ );
33
+ }
34
+ const departments = new Map(
35
+ (await this.departments.all()).map((item) => [item.id, item]),
36
+ );
37
+ const invalidIds = unique(departmentIds).filter(
38
+ (id) => departments.get(id)?.status !== "active",
39
+ );
40
+ return invalidIds.length
41
+ ? invalid(
42
+ "INVALID_ROLE_DEPARTMENT_SCOPE",
43
+ `角色数据权限引用了不存在或未启用的部门:${invalidIds.join(", ")}`,
44
+ )
45
+ : undefined;
46
+ }
47
+ }
48
+
49
+ function invalid(error: string, message: string): ResourcePolicyError {
50
+ return { error, message };
51
+ }
52
+
53
+ function unique(values: string[]): string[] {
54
+ return [...new Set(values)].sort();
55
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUIDv7 } from "bun";
2
2
  import type { AuditEvent, LifecycleStatus } from "@southwind-ai/shared";
3
3
  import type { RequestGuardContext } from "../guards.js";
4
+ import { auditActor } from "../guards.js";
4
5
  import type { ManagedEntity } from "./entities.js";
5
6
  import { parsePagination } from "../pagination.js";
6
7
 
@@ -88,11 +89,7 @@ export function resourceEvent(
88
89
  id: randomUUIDv7(),
89
90
  action,
90
91
  outcome: "success",
91
- actor: {
92
- id: context.actor.id,
93
- type: context.actor.type === "anonymous" ? "user" : context.actor.type,
94
- name: context.actor.name,
95
- },
92
+ actor: auditActor(context),
96
93
  target: {
97
94
  type: resourceName,
98
95
  id,
@@ -5,6 +5,14 @@ import type { ScopedResourceAccess } from "../data-access/scoped-resource.js";
5
5
  import type { RequestActor } from "../guards.js";
6
6
  import type { MutationRunner } from "./mutation-runner.js";
7
7
 
8
+ export interface ResourcePolicyError {
9
+ error: string;
10
+ message: string;
11
+ details?: Record<string, unknown>;
12
+ }
13
+
14
+ export type ResourcePolicyResult = string | ResourcePolicyError | undefined;
15
+
8
16
  export interface RouteApp {
9
17
  get(path: string, handler: (context: unknown) => unknown): unknown;
10
18
  post(
@@ -33,16 +41,17 @@ export interface ResourceRouteConfig<T extends ManagedEntity> {
33
41
  validateInput?: (
34
42
  input: Partial<T>,
35
43
  current?: T,
36
- ) => MaybePromise<string | undefined>;
44
+ ) => MaybePromise<ResourcePolicyResult>;
37
45
  dataAccess:
38
46
  | { readonly mode: "platform" }
39
47
  | { readonly mode: "request"; readonly access: ScopedResourceAccess<T> };
40
48
  projectResponse?: (entity: T) => Record<string, unknown>;
49
+ beforeCreate?: (input: Partial<T>) => MaybePromise<ResourcePolicyResult>;
41
50
  beforeUpdate?: (
42
51
  current: T,
43
52
  input: Partial<T>,
44
- ) => MaybePromise<string | undefined>;
45
- beforeDelete?: (current: T) => MaybePromise<string | undefined>;
53
+ ) => MaybePromise<ResourcePolicyResult>;
54
+ beforeDelete?: (current: T) => MaybePromise<ResourcePolicyResult>;
46
55
  afterUpdate?: (
47
56
  current: T,
48
57
  updated: T,
@@ -41,6 +41,32 @@ describe("system route query validation", () => {
41
41
  error: "INVALID_AUDIT_TIME_RANGE",
42
42
  });
43
43
  });
44
+
45
+ test("审计日志拒绝反向时间范围和过长关键字", async () => {
46
+ const fixture = await createFixture("/api/system/audit-logs");
47
+ const reversed = (await fixture.app.invoke("GET", "/system/audit-logs", {
48
+ guardContext: fixture.guardContext,
49
+ params: {},
50
+ query: {
51
+ occurredAfter: "2026-07-20T09:16:00.000Z",
52
+ occurredBefore: "2026-07-20T09:15:59.999Z",
53
+ },
54
+ })) as Response;
55
+ expect(reversed.status).toBe(400);
56
+ await expect(reversed.json()).resolves.toMatchObject({
57
+ error: "INVALID_AUDIT_TIME_RANGE",
58
+ });
59
+
60
+ const tooLong = (await fixture.app.invoke("GET", "/system/audit-logs", {
61
+ guardContext: fixture.guardContext,
62
+ params: {},
63
+ query: { q: "审".repeat(121) },
64
+ })) as Response;
65
+ expect(tooLong.status).toBe(400);
66
+ await expect(tooLong.json()).resolves.toMatchObject({
67
+ error: "INVALID_AUDIT_QUERY",
68
+ });
69
+ });
44
70
  // @windy-module system.audit end
45
71
  });
46
72
 
@@ -223,7 +223,11 @@ describe("system routes", () => {
223
223
  guardContext,
224
224
  params: {},
225
225
  query: {},
226
- body: { code: "dept-security", name: "安全部" },
226
+ body: {
227
+ code: "dept-security",
228
+ name: "安全部",
229
+ parentId: "dept_platform",
230
+ },
227
231
  });
228
232
 
229
233
  expect(policies).toEqual(["critical", "ordinary"]);
@@ -255,7 +259,11 @@ describe("system routes", () => {
255
259
  guardContext,
256
260
  params: {},
257
261
  query: {},
258
- body: { code: "dept-available", name: "可用部门" },
262
+ body: {
263
+ code: "dept-available",
264
+ name: "可用部门",
265
+ parentId: "dept_platform",
266
+ },
259
267
  }),
260
268
  ).resolves.toMatchObject({ code: "dept-available" });
261
269
  await Bun.sleep(0);
@@ -5,7 +5,10 @@ import { findSystemFeature } from "../route-guards.js";
5
5
  import type { createServerRuntime } from "../runtime.js";
6
6
  import type { ManagedEntity } from "./entities.js";
7
7
  import { registerMenuRoutes } from "./menu-routes.js";
8
- import { registerDepartmentQuickCreateRoute } from "./department-routes.js";
8
+ import {
9
+ registerDepartmentQuickCreateRoute,
10
+ registerDepartmentTransitionRoute,
11
+ } from "./department-routes.js";
9
12
  import {
10
13
  asPartial,
11
14
  notFound,
@@ -40,6 +43,7 @@ import {
40
43
  import { registerUserPlaintextRoutes } from "../data-governance/user-plaintext-routes.js";
41
44
  // @windy-module system.data-governance end
42
45
  import { searchAuditEvent } from "../audit/search-summary.js";
46
+ import { DepartmentOrganizationPolicy } from "./department-organization.js";
43
47
 
44
48
  export type { RouteApp } from "./route-types.js";
45
49
 
@@ -57,6 +61,7 @@ export function registerSystemRoutes(
57
61
  features,
58
62
  options.authRepository,
59
63
  );
64
+ const departmentPolicy = new DepartmentOrganizationPolicy(repositories);
60
65
  // @windy-module system.data-governance begin
61
66
  registerUserPlaintextRoutes(app, identity.users, runtime);
62
67
  // @windy-module system.data-governance end
@@ -66,18 +71,37 @@ export function registerSystemRoutes(
66
71
  repositories.departments,
67
72
  runtime,
68
73
  findSystemFeature(features, "department"),
74
+ departmentPolicy,
75
+ repositories.organizationMutationRunner,
76
+ );
77
+ registerDepartmentTransitionRoute(
78
+ app,
79
+ departmentPolicy,
80
+ repositories.organizationMutationRunner,
81
+ runtime,
82
+ findSystemFeature(features, "department"),
69
83
  );
70
84
  registerResourceRoutes(app, identity.roles, runtime);
71
85
  registerResourceRoutes(
72
86
  app,
73
- resource(
74
- "departments",
75
- "department",
76
- repositories.departments,
77
- features,
78
- ["code", "name"],
79
- { mode: "platform" },
80
- ),
87
+ {
88
+ ...resource(
89
+ "departments",
90
+ "department",
91
+ repositories.departments,
92
+ features,
93
+ ["code", "name"],
94
+ { mode: "platform" },
95
+ ),
96
+ importable: false,
97
+ mutationRunner: repositories.organizationMutationRunner,
98
+ validateInput: (input, current) =>
99
+ departmentPolicy.validateInput(input, current),
100
+ beforeCreate: (input) => departmentPolicy.beforeCreate(input),
101
+ beforeUpdate: (current, input) =>
102
+ departmentPolicy.beforeUpdate(current, input),
103
+ beforeDelete: (current) => departmentPolicy.beforeDelete(current),
104
+ },
81
105
  runtime,
82
106
  );
83
107
  registerMenuRoutes(
@@ -245,28 +269,26 @@ function registerResourceRoutes<T extends ManagedEntity>(
245
269
  config.permissionResource,
246
270
  "manage",
247
271
  config.feature,
248
- async () => {
249
- const input = asPartial<T>(body);
250
- const validationError = await config.validateInput?.(input);
251
- if (validationError) {
252
- return Response.json(
253
- { error: "INVALID_RESOURCE", message: validationError },
254
- { status: 400 },
272
+ async () =>
273
+ runResourceMutation(config, async () => {
274
+ const input = asPartial<T>(body);
275
+ const validationError = await config.validateInput?.(input);
276
+ if (validationError) return invalidResource(validationError);
277
+ const policyError = await config.beforeCreate?.(input);
278
+ if (policyError) return resourceConflict(policyError);
279
+ const item = await accessCall(() =>
280
+ config.dataAccess.mode === "request"
281
+ ? config.dataAccess.access.create(input, guardContext.actor)
282
+ : config.repository.create(input, guardContext.actor),
255
283
  );
256
- }
257
- const item = await accessCall(() =>
258
- config.dataAccess.mode === "request"
259
- ? config.dataAccess.access.create(input, guardContext.actor)
260
- : config.repository.create(input, guardContext.actor),
261
- );
262
- if (item instanceof Response) return item;
263
- await recordMutationAudit(
264
- runtime,
265
- config.path,
266
- resourceEvent(guardContext, "crud.create", config.path, item.id),
267
- );
268
- return projectEntity(config, item);
269
- },
284
+ if (item instanceof Response) return item;
285
+ await recordMutationAudit(
286
+ runtime,
287
+ config.path,
288
+ resourceEvent(guardContext, "crud.create", config.path, item.id),
289
+ );
290
+ return projectEntity(config, item);
291
+ }),
270
292
  );
271
293
  });
272
294
 
@@ -287,12 +309,7 @@ function registerResourceRoutes<T extends ManagedEntity>(
287
309
  if (current instanceof Response) return current;
288
310
  if (!current) return notFound(config.path, id);
289
311
  const validationError = await config.validateInput?.(input, current);
290
- if (validationError) {
291
- return Response.json(
292
- { error: "INVALID_RESOURCE", message: validationError },
293
- { status: 400 },
294
- );
295
- }
312
+ if (validationError) return invalidResource(validationError);
296
313
  const policyError = await config.beforeUpdate?.(current, input);
297
314
  if (policyError) return resourceConflict(policyError);
298
315
  const item = await accessCall(() =>
@@ -352,3 +369,20 @@ function registerResourceRoutes<T extends ManagedEntity>(
352
369
  );
353
370
  });
354
371
  }
372
+
373
+ function invalidResource(
374
+ error: NonNullable<
375
+ Awaited<
376
+ ReturnType<
377
+ NonNullable<ResourceRouteConfig<ManagedEntity>["validateInput"]>
378
+ >
379
+ >
380
+ >,
381
+ ): Response {
382
+ return Response.json(
383
+ typeof error === "string"
384
+ ? { error: "INVALID_RESOURCE", message: error }
385
+ : error,
386
+ { status: 400 },
387
+ );
388
+ }
@@ -26,6 +26,10 @@ import type {
26
26
  ManagedUser,
27
27
  } from "./entities.js";
28
28
  import { userDataScopeBinding } from "./user-data-scope.js";
29
+ import {
30
+ SerialMutationRunner,
31
+ type MutationRunner,
32
+ } from "./mutation-runner.js";
29
33
 
30
34
  export interface SystemRepositories {
31
35
  users: ScopedEntityRepository<ManagedUser>;
@@ -38,6 +42,7 @@ export interface SystemRepositories {
38
42
  licenses: EntityRepository<ManagedLicense>;
39
43
  // @windy-module system.license end
40
44
  menuRegistry: ManifestMenuRegistry;
45
+ organizationMutationRunner: MutationRunner;
41
46
  }
42
47
 
43
48
  export interface CreateSystemRepositoryOptions {
@@ -53,6 +58,7 @@ export function createSystemRepositories(
53
58
  const repositories = createDrizzleSystemRepositories(options.db, seeds);
54
59
  return {
55
60
  ...repositories,
61
+ organizationMutationRunner: repositories.organizationMutationRunner,
56
62
  menuRegistry: new ManifestMenuRegistry(
57
63
  repositories.menus,
58
64
  snapshot.menus,
@@ -81,6 +87,7 @@ export function createSystemRepositories(
81
87
  };
82
88
  return {
83
89
  ...repositories,
90
+ organizationMutationRunner: new SerialMutationRunner(),
84
91
  menuRegistry: new ManifestMenuRegistry(repositories.menus, snapshot.menus),
85
92
  };
86
93
  }
@@ -47,12 +47,19 @@ describePostgres("PostgreSQL Work Order data-scope enforcement", () => {
47
47
  features: [],
48
48
  licenses: [],
49
49
  });
50
- await repositories.departments.create(department("dept_a"), setupActor);
50
+ await repositories.departments.create(department("org_root"), setupActor);
51
+ await repositories.departments.create(
52
+ department("dept_a", "org_root"),
53
+ setupActor,
54
+ );
51
55
  await repositories.departments.create(
52
56
  department("dept_a_child", "dept_a"),
53
57
  setupActor,
54
58
  );
55
- await repositories.departments.create(department("dept_b"), setupActor);
59
+ await repositories.departments.create(
60
+ department("dept_b", "org_root"),
61
+ setupActor,
62
+ );
56
63
  const workOrders = createDrizzleWorkOrderRepository(database);
57
64
  await workOrders.create(
58
65
  ticket("ticket_self", "user_a", "dept_a"),
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { WorkOrder } from "@southwind-ai/example-work-order";
3
+ import { createWorkOrderOpenSearchIndex } from "./opensearch-index.js";
4
+
5
+ describe("工单 OpenSearch 可重建索引", () => {
6
+ test("同步业务事实后把 dataScope 编译进 OpenSearch 查询", async () => {
7
+ const requests: Request[] = [];
8
+ const fetcher = async (
9
+ input: string | URL | Request,
10
+ init?: RequestInit,
11
+ ) => {
12
+ const request = new Request(input, init);
13
+ requests.push(request);
14
+ if (request.method === "HEAD") return new Response(null, { status: 404 });
15
+ if (request.url.endsWith("/_bulk?refresh=wait_for")) {
16
+ return Response.json({ errors: false });
17
+ }
18
+ if (request.url.endsWith("/_search")) {
19
+ return Response.json({
20
+ hits: {
21
+ hits: [
22
+ {
23
+ _id: "ticket_1",
24
+ _score: 3,
25
+ sort: [3, "2026-07-26T00:00:00.000Z", "ticket_1"],
26
+ },
27
+ ],
28
+ },
29
+ });
30
+ }
31
+ return Response.json({});
32
+ };
33
+ const index = createWorkOrderOpenSearchIndex(
34
+ { OPENSEARCH_URL: "http://opensearch:9200" },
35
+ fetcher,
36
+ )!;
37
+
38
+ await index.synchronize(async () => [workOrder()]);
39
+ const result = await index.search("网络", 1, undefined, {
40
+ actorId: "user_1",
41
+ branches: [{ kind: "owner", ownerId: "user_1" }],
42
+ });
43
+
44
+ expect(result.hits).toEqual([{ id: "ticket_1", score: 3 }]);
45
+ expect(result.nextCursor).toBeString();
46
+ const searchRequest = requests.find(({ url }) => url.endsWith("/_search"));
47
+ const body = (await searchRequest?.json()) as {
48
+ query: { bool: { filter: unknown[] } };
49
+ };
50
+ expect(JSON.stringify(body.query.bool.filter)).toContain(
51
+ '"ownerId":"user_1"',
52
+ );
53
+ expect(
54
+ requests.find(({ url }) => url.endsWith("/_bulk?refresh=wait_for")),
55
+ ).toBeDefined();
56
+ });
57
+
58
+ test("无范围授权时使用 match_none", async () => {
59
+ let searchBody = "";
60
+ const index = createWorkOrderOpenSearchIndex(
61
+ { OPENSEARCH_URL: "http://opensearch:9200" },
62
+ async (input, init) => {
63
+ const request = new Request(input, init);
64
+ if (request.method === "HEAD")
65
+ return new Response(null, { status: 200 });
66
+ if (request.url.endsWith("/_search")) {
67
+ searchBody = await request.text();
68
+ return Response.json({ hits: { hits: [] } });
69
+ }
70
+ return Response.json({});
71
+ },
72
+ )!;
73
+ await index.synchronize(async () => []);
74
+ await index.search("工单", 5, undefined, {
75
+ actorId: "user_none",
76
+ branches: [],
77
+ });
78
+ expect(searchBody).toContain('"match_none"');
79
+ });
80
+ });
81
+
82
+ function workOrder(): WorkOrder {
83
+ return {
84
+ id: "ticket_1",
85
+ title: "办公网络中断",
86
+ description: "网络连接不稳定",
87
+ priority: "high",
88
+ workflowStatus: "open",
89
+ ownerId: "user_1",
90
+ departmentId: "dept_1",
91
+ status: "active",
92
+ createdAt: "2026-07-26T00:00:00.000Z",
93
+ createdBy: "user_1",
94
+ updatedAt: "2026-07-26T00:00:00.000Z",
95
+ updatedBy: "user_1",
96
+ };
97
+ }