create-windy 0.6.20 → 0.7.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 (126) hide show
  1. package/dist/cli.js +16 -4
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/agent/remote-runtime.test.ts +36 -0
  5. package/template/apps/server/src/agent/remote-runtime.ts +15 -7
  6. package/template/apps/server/src/audit/policy.ts +6 -1
  7. package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
  8. package/template/apps/server/src/auth/composition.ts +5 -0
  9. package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
  10. package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
  11. package/template/apps/server/src/auth/credential-service.ts +47 -12
  12. package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
  13. package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
  14. package/template/apps/server/src/auth/repository.ts +9 -6
  15. package/template/apps/server/src/auth/routes.test-support.ts +1 -0
  16. package/template/apps/server/src/auth/runtime.ts +4 -0
  17. package/template/apps/server/src/auth/service-options.ts +2 -0
  18. package/template/apps/server/src/auth/service.ts +2 -0
  19. package/template/apps/server/src/auth/user-credential-access.ts +37 -0
  20. package/template/apps/server/src/foundation-activation.test.ts +24 -1
  21. package/template/apps/server/src/foundation.ts +0 -2
  22. package/template/apps/server/src/index.ts +1 -0
  23. package/template/apps/server/src/runtime-development.ts +2 -0
  24. package/template/apps/server/src/runtime.test.ts +13 -4
  25. package/template/apps/server/src/system/department-organization.ts +58 -59
  26. package/template/apps/server/src/system/department-read-routes.ts +54 -0
  27. package/template/apps/server/src/system/department-routes.test.ts +27 -19
  28. package/template/apps/server/src/system/department-routes.ts +95 -33
  29. package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
  30. package/template/apps/server/src/system/department-validation.ts +79 -0
  31. package/template/apps/server/src/system/drizzle-repository.ts +14 -0
  32. package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
  33. package/template/apps/server/src/system/identity-read-model.ts +105 -0
  34. package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
  35. package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
  36. package/template/apps/server/src/system/identity-route-config.ts +29 -7
  37. package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
  38. package/template/apps/server/src/system/identity-write-validation.ts +170 -0
  39. package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
  40. package/template/apps/server/src/system/redis-health.test.ts +1 -1
  41. package/template/apps/server/src/system/repository.ts +29 -2
  42. package/template/apps/server/src/system/resource-routes.ts +116 -72
  43. package/template/apps/server/src/system/route-audit.test.ts +117 -0
  44. package/template/apps/server/src/system/route-helpers.ts +14 -0
  45. package/template/apps/server/src/system/route-types.ts +11 -2
  46. package/template/apps/server/src/system/routes.test.ts +0 -68
  47. package/template/apps/server/src/system/routes.ts +29 -2
  48. package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
  49. package/template/apps/server/src/system/user-account-policy.ts +26 -9
  50. package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
  51. package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
  52. package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
  53. package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
  54. package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
  55. package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
  56. package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
  57. package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
  58. package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
  59. package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
  60. package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
  61. package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
  62. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
  63. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
  64. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
  65. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
  66. package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
  67. package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
  68. package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
  69. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
  70. package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
  71. package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
  72. package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
  73. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
  74. package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
  75. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
  76. package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
  77. package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
  78. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
  79. package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
  80. package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
  81. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
  82. package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
  83. package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
  84. package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
  85. package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
  86. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
  87. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
  88. package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
  89. package/template/apps/web/src/pages/system/resource-config.ts +2 -0
  90. package/template/apps/web/src/services/system-api.ts +25 -0
  91. package/template/docs/architecture/foundation-infrastructure.md +12 -25
  92. package/template/docs/development/crud-generator.md +1 -1
  93. package/template/docs/operations/legacy-data-migration.md +73 -0
  94. package/template/docs/platform/bulk-data-jobs.md +1 -1
  95. package/template/docs/platform/module-configuration.md +2 -2
  96. package/template/docs/platform/organization-membership.md +37 -6
  97. package/template/docs/platform/scheduled-task-executor.md +1 -1
  98. package/template/package.json +2 -1
  99. package/template/packages/config/index.test.ts +1 -5
  100. package/template/packages/config/package.json +2 -2
  101. package/template/packages/config/src/platform-boundary.test.ts +39 -0
  102. package/template/packages/config/src/platform.ts +0 -28
  103. package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
  104. package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
  105. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  106. package/template/packages/database/package.json +2 -2
  107. package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
  108. package/template/packages/database/src/schema/index.ts +1 -0
  109. package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
  110. package/template/packages/database/src/schema/license-v1.ts +5 -0
  111. package/template/packages/jobs/package.json +2 -2
  112. package/template/packages/modules/package.json +1 -1
  113. package/template/packages/modules/src/system-api-permissions.ts +14 -0
  114. package/template/packages/modules/src/system-features.ts +0 -11
  115. package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
  116. package/template/packages/modules/src/system-module-catalog.ts +0 -1
  117. package/template/packages/server-sdk/index.ts +1 -49
  118. package/template/packages/server-sdk/package.json +3 -2
  119. package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
  120. package/template/packages/shared/index.ts +2 -1
  121. package/template/packages/shared/package.json +1 -1
  122. package/template/packages/shared/src/license-console-capabilities.ts +163 -0
  123. package/template/packages/shared/src/license-console.ts +94 -0
  124. package/template/packages/shared/src/license.ts +5 -0
  125. package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
  126. package/template/packages/shared/src/ruoyi.ts +0 -98
@@ -4,6 +4,7 @@ import type { DataAccessRolePolicy } from "../data-access/context.js";
4
4
  import { buildActorScopeContexts } from "../data-access/context.js";
5
5
 
6
6
  export interface AuthUserRecord {
7
+ deletedAt?: Date;
7
8
  id: string;
8
9
  username: string;
9
10
  displayName: string;
@@ -100,7 +101,7 @@ export class InMemoryAuthRepository implements AuthRepository {
100
101
  username: string,
101
102
  ): Promise<AuthUserRecord | undefined> {
102
103
  const user = [...this.users.values()].find(
103
- (item) => item.username === username,
104
+ (item) => item.username === username && !item.deletedAt,
104
105
  );
105
106
  return user ? structuredClone(user) : undefined;
106
107
  }
@@ -109,7 +110,9 @@ export class InMemoryAuthRepository implements AuthRepository {
109
110
  reference: string,
110
111
  ): Promise<AuthUserRecord | undefined> {
111
112
  const user = [...this.users.values()].find(
112
- (item) => item.id === reference || item.username === reference,
113
+ (item) =>
114
+ !item.deletedAt &&
115
+ (item.id === reference || item.username === reference),
113
116
  );
114
117
  return user ? structuredClone(user) : undefined;
115
118
  }
@@ -141,7 +144,7 @@ export class InMemoryAuthRepository implements AuthRepository {
141
144
  return undefined;
142
145
  }
143
146
  const user = this.users.get(session.userId);
144
- if (!user || user.status !== "active") {
147
+ if (!user || user.deletedAt || user.status !== "active") {
145
148
  return undefined;
146
149
  }
147
150
  const current = this.sessions.get(session.id);
@@ -177,7 +180,7 @@ export class InMemoryAuthRepository implements AuthRepository {
177
180
  void updatedAt;
178
181
  void updatedBy;
179
182
  const user = this.users.get(userId);
180
- if (!user) {
183
+ if (!user || user.deletedAt) {
181
184
  return false;
182
185
  }
183
186
  user.passwordHash = update.passwordHash;
@@ -195,7 +198,7 @@ export class InMemoryAuthRepository implements AuthRepository {
195
198
  void updatedAt;
196
199
  void updatedBy;
197
200
  const user = this.users.get(userId);
198
- if (!user) return undefined;
201
+ if (!user || user.deletedAt) return undefined;
199
202
  user.displayName = input.displayName;
200
203
  user.email = input.email;
201
204
  user.phone = input.phone;
@@ -216,7 +219,7 @@ export class InMemoryAuthRepository implements AuthRepository {
216
219
  })
217
220
  .flatMap((session) => {
218
221
  const user = this.users.get(session.userId);
219
- return user?.status === "active"
222
+ return user?.status === "active" && !user.deletedAt
220
223
  ? [
221
224
  {
222
225
  sessionId: session.id,
@@ -20,6 +20,7 @@ export function authFixture(
20
20
  const events: AuditEvent[] = [];
21
21
  const runtime = createServerRuntime({}, { actorResolver: resolver });
22
22
  const service = new AuthService(repository, {
23
+ canManageUser: async () => true,
23
24
  passwordPolicy,
24
25
  sessionTtlSeconds,
25
26
  onSessionsRevoked,
@@ -6,6 +6,8 @@ import { createArgon2PasswordHasher } from "./password.js";
6
6
  import { ReauthenticationService } from "./reauthentication.js";
7
7
 
8
8
  interface AuthRuntimeInput {
9
+ credentialMutationRunner?: import("../system/mutation-runner.js").MutationRunner;
10
+ canManageUser?: import("./user-credential-access.js").UserCredentialAccess;
9
11
  repository?: AuthRepository;
10
12
  sessionTtlSeconds: number | (() => number);
11
13
  passwordPolicy?: () => PasswordPolicy;
@@ -19,6 +21,8 @@ export function createAuthRuntime(input: AuthRuntimeInput) {
19
21
  const passwordHasher = createArgon2PasswordHasher(input.passwordPolicy);
20
22
  const authService = new AuthService(authRepository, {
21
23
  sessionTtlSeconds: input.sessionTtlSeconds,
24
+ canManageUser: input.canManageUser,
25
+ credentialMutationRunner: input.credentialMutationRunner,
22
26
  passwordPolicy: input.passwordPolicy,
23
27
  auditWriter: { write: input.auditWriter },
24
28
  auditReader: input.auditReader,
@@ -4,6 +4,8 @@ import type { AuthenticationProviderRegistry } from "./authentication-provider.j
4
4
  import type { PasswordHasher } from "./password.js";
5
5
 
6
6
  export interface AuthServiceOptions {
7
+ credentialMutationRunner?: import("../system/mutation-runner.js").MutationRunner;
8
+ canManageUser?: import("./user-credential-access.js").UserCredentialAccess;
7
9
  sessionTtlSeconds?: number | (() => number);
8
10
  passwordPolicy?: () => PasswordPolicy;
9
11
  now?: () => Date;
@@ -81,6 +81,8 @@ export class AuthService {
81
81
  this.passwordHasher,
82
82
  options.auditWriter,
83
83
  this.now,
84
+ options.canManageUser,
85
+ options.credentialMutationRunner,
84
86
  );
85
87
  this.ownSessions = new OwnSessionService(
86
88
  repository,
@@ -0,0 +1,37 @@
1
+ import { IdentityRoleAssignment } from "../system/identity-role-assignment.js";
2
+ import type { RequestActor } from "../guards.js";
3
+ import { departmentDescendantResolver } from "../data-access/department-tree.js";
4
+ import {
5
+ DataScopedResourceService,
6
+ DataAccessDeniedError,
7
+ } from "../data-access/scoped-resource.js";
8
+ import type { SystemRepositories } from "../system/seed.js";
9
+
10
+ export type UserCredentialAccess = (
11
+ id: string,
12
+ actor: RequestActor,
13
+ ) => Promise<boolean>;
14
+
15
+ export function createUserCredentialAccess(
16
+ repositories: SystemRepositories,
17
+ ): UserCredentialAccess {
18
+ const access = new DataScopedResourceService(
19
+ repositories.users,
20
+ departmentDescendantResolver(repositories.departments),
21
+ );
22
+ const assignment = new IdentityRoleAssignment(
23
+ repositories.departments,
24
+ repositories.roles,
25
+ );
26
+ return async (id, actor) => {
27
+ try {
28
+ const target = await access.get(id, actor);
29
+ return Boolean(
30
+ target && !(await assignment.validateUser({}, target, actor)),
31
+ );
32
+ } catch (error) {
33
+ if (error instanceof DataAccessDeniedError) return false;
34
+ throw error;
35
+ }
36
+ };
37
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { loadPlatformConfig } from "@southwind-ai/config";
2
3
  import {
3
4
  // @windy-module system.settings begin
4
5
  composeModuleMenus,
@@ -6,9 +7,31 @@ import {
6
7
  // @windy-module system.settings end
7
8
  systemModuleManifests,
8
9
  } from "@southwind-ai/modules";
9
- import { applyPlatformModuleActivation } from "./foundation.js";
10
+ import {
11
+ applyPlatformModuleActivation,
12
+ createFoundationSnapshot,
13
+ } from "./foundation.js";
10
14
 
11
15
  describe("平台 Module 运行时启停", () => {
16
+ test("平台快照只公开当前运行能力,不携带旧库兼容状态", () => {
17
+ const snapshot = createFoundationSnapshot(
18
+ loadPlatformConfig({}),
19
+ systemModuleManifests(),
20
+ );
21
+
22
+ expect(Object.keys(snapshot.platform).sort()).toEqual([
23
+ "auditEnabled",
24
+ "databaseDialect",
25
+ "enableAI",
26
+ "licenseEnabled",
27
+ "name",
28
+ ]);
29
+ expect(snapshot.platform.databaseDialect).toBe("postgresql");
30
+ expect(snapshot.features.map(({ key }) => key)).not.toContain(
31
+ "system.ruoyi-compat",
32
+ );
33
+ });
34
+
12
35
  // @windy-module system.operations begin
13
36
  test("停用只关闭能力与入口,保留静态实现声明用于一致性校验", () => {
14
37
  const source = systemModuleManifests().find(
@@ -26,7 +26,6 @@ export interface FoundationSnapshot {
26
26
  databaseDialect: string;
27
27
  licenseEnabled: boolean;
28
28
  auditEnabled: boolean;
29
- ruoyiCompatibilityEnabled: boolean;
30
29
  };
31
30
  modules: Array<
32
31
  Pick<ModuleManifest, "name" | "version" | "label" | "description">
@@ -147,7 +146,6 @@ export function createFoundationSnapshot(
147
146
  metadata?.runtimeActivation !== "disabled",
148
147
  ),
149
148
  auditEnabled: config.audit.enabled,
150
- ruoyiCompatibilityEnabled: config.compatibility.ruoyiVue.enabled,
151
149
  },
152
150
  modules: modules.map(({ name, version, label, description }) => ({
153
151
  name,
@@ -113,6 +113,7 @@ const {
113
113
  reauthentication,
114
114
  // @windy-module system.data-governance end
115
115
  } = createComposedAuthRuntime({
116
+ repositories: systemRepositories,
116
117
  platformConfig,
117
118
  persistence,
118
119
  runtime,
@@ -23,6 +23,8 @@ export function developmentAdminActor(): RequestActor {
23
23
  permissionKeys: [
24
24
  "system.admin.access",
25
25
  "system.menu.read",
26
+ "system.profile.read",
27
+ "system.profile.manage",
26
28
  "system.user.read",
27
29
  "system.user.manage",
28
30
  "system.user-directory.read",
@@ -22,6 +22,15 @@ describe("server runtime", () => {
22
22
 
23
23
  expect(context.actor.id).toBe("user_dev_admin");
24
24
  expect(context.actor.permissionKeys).toContain("system.menu.read");
25
+ const profilePermissions = createFoundationSnapshot()
26
+ .permissions.filter(({ key }) => key.startsWith("system.profile."))
27
+ .map(({ key }) => key);
28
+ expect(profilePermissions).toEqual(
29
+ expect.arrayContaining(["system.profile.read", "system.profile.manage"]),
30
+ );
31
+ expect(context.actor.permissionKeys).toEqual(
32
+ expect.arrayContaining(profilePermissions),
33
+ );
25
34
  // @windy-module work-order begin
26
35
  expect(context.actor.permissionKeys).toContain("work-order.ticket.read");
27
36
  // @windy-module work-order end
@@ -58,7 +67,7 @@ describe("server runtime", () => {
58
67
 
59
68
  test("未知 token 保持匿名,License 可由环境变量提供 Entitlement", async () => {
60
69
  const runtime = createServerRuntime({
61
- WINDY_LICENSE_ENTITLEMENTS: "capability.ruoyi-compat, capability.agent",
70
+ WINDY_LICENSE_ENTITLEMENTS: "capability.example, capability.agent",
62
71
  });
63
72
  const request = new Request("http://localhost/api/foundation", {
64
73
  headers: { authorization: "Bearer missing" },
@@ -68,7 +77,7 @@ describe("server runtime", () => {
68
77
 
69
78
  expect(context.actor.type).toBe("anonymous");
70
79
  expect(context.license?.entitlements).toEqual([
71
- entitlementKey("capability.ruoyi-compat"),
80
+ entitlementKey("capability.example"),
72
81
  entitlementKey("capability.agent"),
73
82
  ]);
74
83
  });
@@ -186,7 +195,7 @@ describe("server runtime", () => {
186
195
  licenseResolver: {
187
196
  async resolve() {
188
197
  return {
189
- entitlements: [entitlementKey("capability.ruoyi-compat")],
198
+ entitlements: [entitlementKey("capability.example")],
190
199
  };
191
200
  },
192
201
  },
@@ -197,7 +206,7 @@ describe("server runtime", () => {
197
206
  const context = await runtime.createGuardContext(request);
198
207
 
199
208
  expect(context.license?.entitlements).toEqual([
200
- entitlementKey("capability.ruoyi-compat"),
209
+ entitlementKey("capability.example"),
201
210
  ]);
202
211
  });
203
212
 
@@ -1,4 +1,5 @@
1
- import type { LifecycleStatus } from "@southwind-ai/shared";
1
+ import { validateDepartmentInput } from "./department-validation.js";
2
+ import { resolveSystemActorForUser } from "./actor.js";
2
3
  import type { RequestActor } from "../guards.js";
3
4
  import type {
4
5
  ManagedDepartment,
@@ -27,58 +28,22 @@ interface OrganizationRepositories {
27
28
  }
28
29
 
29
30
  export class DepartmentOrganizationPolicy {
30
- constructor(private readonly repositories: OrganizationRepositories) {}
31
+ constructor(
32
+ private readonly repositories: OrganizationRepositories,
33
+ private readonly changed?: (
34
+ actors: RequestActor[],
35
+ occurredAt: string,
36
+ ) => Promise<void>,
37
+ ) {}
31
38
 
32
- async validateInput(
33
- input: Partial<ManagedDepartment>,
34
- current?: ManagedDepartment,
35
- ): Promise<ResourcePolicyResult> {
36
- if (
37
- input.code !== undefined &&
38
- (typeof input.code !== "string" ||
39
- !/^[a-zA-Z][a-zA-Z0-9_-]{1,79}$/.test(input.code.trim()))
40
- ) {
41
- return invalid(
42
- "INVALID_DEPARTMENT_CODE",
43
- "部门编码需以字母开头且至少 2 位",
44
- );
45
- }
46
- if (
47
- input.name !== undefined &&
48
- (typeof input.name !== "string" || !input.name.trim())
49
- ) {
50
- return invalid("INVALID_DEPARTMENT_NAME", "部门名称不能为空");
51
- }
52
- if (
53
- input.order !== undefined &&
54
- (!Number.isSafeInteger(input.order) || input.order < 0)
55
- ) {
56
- return invalid("INVALID_DEPARTMENT_ORDER", "部门排序必须是非负整数");
57
- }
58
- if (input.status !== undefined && !isLifecycleStatus(input.status)) {
59
- return invalid("INVALID_DEPARTMENT_STATUS", "部门状态不合法");
60
- }
61
- if (
62
- !current &&
63
- (typeof input.code !== "string" ||
64
- typeof input.name !== "string" ||
65
- typeof input.parentId !== "string" ||
66
- !input.parentId)
67
- ) {
68
- return invalid(
69
- "DEPARTMENT_PARENT_REQUIRED",
70
- "平台只允许一个根组织,新部门必须选择上级部门",
71
- );
72
- }
73
- return undefined;
74
- }
39
+ validateInput = validateDepartmentInput;
75
40
 
76
41
  async beforeCreate(
77
42
  input: Partial<ManagedDepartment>,
78
43
  ): Promise<ResourcePolicyResult> {
79
- const duplicate = (await this.repositories.departments.all()).find(
80
- (department) => department.code === input.code?.trim(),
81
- );
44
+ const duplicate = (
45
+ await this.repositories.departments.all({ includeDeleted: true })
46
+ ).find((department) => department.code === input.code?.trim());
82
47
  if (duplicate) {
83
48
  return conflict("DUPLICATE_DEPARTMENT_CODE", "部门编码已存在");
84
49
  }
@@ -90,7 +55,9 @@ export class DepartmentOrganizationPolicy {
90
55
  input: Partial<ManagedDepartment>,
91
56
  ): Promise<ResourcePolicyResult> {
92
57
  if (input.code && input.code !== current.code) {
93
- const duplicate = (await this.repositories.departments.all()).find(
58
+ const duplicate = (
59
+ await this.repositories.departments.all({ includeDeleted: true })
60
+ ).find(
94
61
  (department) =>
95
62
  department.id !== current.id &&
96
63
  department.code === input.code?.trim(),
@@ -111,7 +78,7 @@ export class DepartmentOrganizationPolicy {
111
78
  return this.validateCanLeaveActive(current);
112
79
  }
113
80
  if (input.status === "active") {
114
- return this.validateParent(current, current.parentId);
81
+ return this.validateParent(current, input.parentId ?? current.parentId);
115
82
  }
116
83
  return undefined;
117
84
  }
@@ -125,6 +92,7 @@ export class DepartmentOrganizationPolicy {
125
92
  action: DepartmentTransitionAction,
126
93
  targetDepartmentId: string | undefined,
127
94
  actor: RequestActor,
95
+ updates: Partial<ManagedDepartment> = {},
128
96
  ): Promise<DepartmentTransitionResult | ResourcePolicyError> {
129
97
  const source = await this.repositories.departments.get(id);
130
98
  if (!source) {
@@ -133,6 +101,13 @@ export class DepartmentOrganizationPolicy {
133
101
  if (!source.parentId) {
134
102
  return conflict("ROOT_DEPARTMENT_IMMUTABLE", "根组织不能停用或删除");
135
103
  }
104
+ const updateError =
105
+ (await this.validateInput(updates, source)) ||
106
+ (await this.beforeUpdate(source, updates));
107
+ if (updateError)
108
+ return typeof updateError === "string"
109
+ ? conflict("INVALID_DEPARTMENT", updateError)
110
+ : updateError;
136
111
  const references = await this.references(source.id);
137
112
  const referenceCount =
138
113
  references.children.length +
@@ -156,6 +131,15 @@ export class DepartmentOrganizationPolicy {
156
131
  if (targetError) return targetError;
157
132
  }
158
133
 
134
+ const previousActors = this.changed
135
+ ? (
136
+ await Promise.all(
137
+ (
138
+ await this.repositories.users.all({ status: "active" })
139
+ ).map((user) => resolveSystemActorForUser(this.repositories, user)),
140
+ )
141
+ ).filter((value): value is RequestActor => !!value)
142
+ : [];
159
143
  if (target) {
160
144
  await this.repositories.departments.updateMany(
161
145
  references.children.map((child) => ({
@@ -190,12 +174,13 @@ export class DepartmentOrganizationPolicy {
190
174
  ? await this.repositories.departments.softDelete(source.id, actor)
191
175
  : await this.repositories.departments.update(
192
176
  source.id,
193
- { status: "disabled" },
177
+ { ...updates, status: "disabled" },
194
178
  actor,
195
179
  );
196
180
  if (!department) {
197
181
  return conflict("DEPARTMENT_NOT_FOUND", "部门不存在或已删除");
198
182
  }
183
+ await this.changed?.(previousActors, department.updatedAt);
199
184
  return {
200
185
  department,
201
186
  movedChildren: references.children.length,
@@ -277,6 +262,28 @@ export class DepartmentOrganizationPolicy {
277
262
  return false;
278
263
  }
279
264
 
265
+ async afterUpdate(current: ManagedDepartment, updated: ManagedDepartment) {
266
+ if (
267
+ !this.changed ||
268
+ (current.parentId === updated.parentId &&
269
+ current.status === updated.status)
270
+ )
271
+ return;
272
+ const users = await this.repositories.users.all({ status: "active" });
273
+ const actors = (
274
+ await Promise.all(
275
+ users.map((user) => resolveSystemActorForUser(this.repositories, user)),
276
+ )
277
+ ).filter((value): value is RequestActor => !!value);
278
+ await this.changed(actors, updated.updatedAt);
279
+ }
280
+
281
+ async preview(departmentId: string) {
282
+ if (!(await this.repositories.departments.get(departmentId)))
283
+ return undefined;
284
+ return referenceDetails(await this.references(departmentId));
285
+ }
286
+
280
287
  private async references(departmentId: string) {
281
288
  const departments = await this.repositories.departments.all();
282
289
  const users = await this.repositories.users.all();
@@ -299,10 +306,6 @@ function referenceDetails(
299
306
  };
300
307
  }
301
308
 
302
- function invalid(error: string, message: string): ResourcePolicyError {
303
- return { error, message };
304
- }
305
-
306
309
  function conflict(
307
310
  error: string,
308
311
  message: string,
@@ -314,7 +317,3 @@ function conflict(
314
317
  function unique(values: string[]): string[] {
315
318
  return [...new Set(values)].sort();
316
319
  }
317
-
318
- function isLifecycleStatus(value: unknown): value is LifecycleStatus {
319
- return ["draft", "active", "disabled", "archived"].includes(String(value));
320
- }
@@ -0,0 +1,54 @@
1
+ import type { MutationRunner } from "./mutation-runner.js";
2
+ import { accessCall } from "./resource-access-response.js";
3
+ import type { FeatureFlagDefinition } from "@southwind-ai/shared";
4
+ import type { createServerRuntime } from "../runtime.js";
5
+ import { evaluateRouteGuards, findSystemFeature } from "../route-guards.js";
6
+ import { forbidden, routeContext } from "./route-helpers.js";
7
+ import type { DepartmentOrganizationPolicy } from "./department-organization.js";
8
+ import type { identityReadModel } from "./identity-read-model.js";
9
+ import type { RouteApp } from "./route-types.js";
10
+
11
+ export function registerDepartmentReadRoutes(
12
+ app: RouteApp,
13
+ policy: DepartmentOrganizationPolicy,
14
+ readModel: ReturnType<typeof identityReadModel>,
15
+ runtime: ReturnType<typeof createServerRuntime>,
16
+ features: FeatureFlagDefinition[],
17
+ mutationRunner: MutationRunner,
18
+ ) {
19
+ app.get("/system/departments/organization", async (raw) => {
20
+ const { guardContext } = routeContext(raw);
21
+ const decision = await evaluateRouteGuards(runtime, guardContext, {
22
+ permission: {
23
+ method: "GET",
24
+ path: "/api/system/departments/organization",
25
+ permissionKey: "system.department.read",
26
+ },
27
+ feature: findSystemFeature(features, "department"),
28
+ });
29
+ if (!decision.allowed) return forbidden(decision.reason);
30
+ return accessCall(() => readModel.organization(guardContext));
31
+ });
32
+ app.get("/system/departments/:id/transition-preview", async (raw) => {
33
+ const { guardContext, params } = routeContext(raw);
34
+ const decision = await evaluateRouteGuards(runtime, guardContext, {
35
+ permission: {
36
+ method: "GET",
37
+ path: "/api/system/departments/:id/transition-preview",
38
+ permissionKey: "system.department.manage",
39
+ },
40
+ feature: findSystemFeature(features, "department"),
41
+ });
42
+ if (!decision.allowed) return forbidden(decision.reason);
43
+ const result = await mutationRunner.run(() =>
44
+ policy.preview(String(params.id ?? "")),
45
+ );
46
+ return (
47
+ result ??
48
+ Response.json(
49
+ { error: "NOT_FOUND", message: "部门不存在或已删除" },
50
+ { status: 404 },
51
+ )
52
+ );
53
+ });
54
+ }
@@ -95,9 +95,15 @@ describe("部门组织路由", () => {
95
95
  });
96
96
 
97
97
  test("移动部门会拒绝自身、下级和根组织变更", async () => {
98
- const { app, context } = await fixture();
99
- await createDepartment(app, context, "dept_a", "部门 A", "dept_platform");
100
- await createDepartment(app, context, "dept_b", "部门 B", "dept_a");
98
+ const { app, context, repositories } = await fixture();
99
+ await createDepartment(
100
+ repositories,
101
+ context,
102
+ "dept_a",
103
+ "部门 A",
104
+ "dept_platform",
105
+ );
106
+ await createDepartment(repositories, context, "dept_b", "部门 B", "dept_a");
101
107
 
102
108
  const cycle = (await app.invoke("PUT", "/system/departments/:id", {
103
109
  ...context,
@@ -123,22 +129,21 @@ describe("部门组织路由", () => {
123
129
  test("有关联数据时要求显式迁移,并原子更新子级、用户和角色范围", async () => {
124
130
  const { app, context, repositories, runtime } = await fixture();
125
131
  await createDepartment(
126
- app,
132
+ repositories,
127
133
  context,
128
134
  "dept_source",
129
135
  "待处置部门",
130
136
  "dept_platform",
131
137
  );
132
138
  await createDepartment(
133
- app,
139
+ repositories,
134
140
  context,
135
141
  "dept_child",
136
142
  "下级部门",
137
143
  "dept_source",
138
144
  );
139
- await app.invoke("POST", "/system/roles", {
140
- ...context,
141
- body: {
145
+ await repositories.roles.create(
146
+ {
142
147
  id: "role_custom",
143
148
  code: "custom-scope",
144
149
  name: "自定义范围",
@@ -147,17 +152,18 @@ describe("部门组织路由", () => {
147
152
  permissionKeys: [],
148
153
  menuKeys: [],
149
154
  },
150
- });
151
- await app.invoke("POST", "/system/users", {
152
- ...context,
153
- body: {
155
+ context.guardContext.actor,
156
+ );
157
+ await repositories.users.create(
158
+ {
154
159
  id: "user_source",
155
160
  username: "source-user",
156
161
  displayName: "来源用户",
157
162
  roleIds: ["role_custom"],
158
163
  departmentId: "dept_source",
159
164
  },
160
- });
165
+ context.guardContext.actor,
166
+ );
161
167
 
162
168
  const blocked = (await app.invoke("DELETE", "/system/departments/:id", {
163
169
  ...context,
@@ -218,6 +224,8 @@ describe("部门组织路由", () => {
218
224
  name: "无效范围",
219
225
  dataScope: "custom",
220
226
  departmentIds: ["dept_missing"],
227
+ permissionKeys: [],
228
+ menuKeys: [],
221
229
  },
222
230
  })) as Response;
223
231
  expect(response.status).toBe(400);
@@ -228,14 +236,14 @@ describe("部门组织路由", () => {
228
236
  });
229
237
 
230
238
  function createDepartment(
231
- app: FakeRouteApp,
232
- context: Record<string, unknown>,
239
+ repositories: Awaited<ReturnType<typeof fixture>>["repositories"],
240
+ context: Awaited<ReturnType<typeof fixture>>["context"],
233
241
  id: string,
234
242
  name: string,
235
243
  parentId: string,
236
244
  ) {
237
- return app.invoke("POST", "/system/departments", {
238
- ...context,
239
- body: { id, code: id, name, parentId, order: 10 },
240
- });
245
+ return repositories.departments.create(
246
+ { id, code: id, name, parentId, order: 10 },
247
+ context.guardContext.actor,
248
+ );
241
249
  }