create-windy 0.6.21 → 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.
- package/dist/cli.js +11 -4
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/src/audit/policy.ts +6 -1
- package/template/apps/server/src/auth/bootstrap-admin.ts +10 -1
- package/template/apps/server/src/auth/composition.ts +5 -0
- package/template/apps/server/src/auth/credential-authorization.test.ts +257 -0
- package/template/apps/server/src/auth/credential-routes.test.ts +1 -0
- package/template/apps/server/src/auth/credential-service.ts +47 -12
- package/template/apps/server/src/auth/drizzle-repository.ts +13 -5
- package/template/apps/server/src/auth/drizzle-session-operations.ts +1 -0
- package/template/apps/server/src/auth/repository.ts +9 -6
- package/template/apps/server/src/auth/routes.test-support.ts +1 -0
- package/template/apps/server/src/auth/runtime.ts +4 -0
- package/template/apps/server/src/auth/service-options.ts +2 -0
- package/template/apps/server/src/auth/service.ts +2 -0
- package/template/apps/server/src/auth/user-credential-access.ts +37 -0
- package/template/apps/server/src/foundation-activation.test.ts +24 -1
- package/template/apps/server/src/foundation.ts +0 -2
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/runtime-development.ts +2 -0
- package/template/apps/server/src/runtime.test.ts +13 -4
- package/template/apps/server/src/system/department-organization.ts +58 -59
- package/template/apps/server/src/system/department-read-routes.ts +54 -0
- package/template/apps/server/src/system/department-routes.test.ts +27 -19
- package/template/apps/server/src/system/department-routes.ts +95 -33
- package/template/apps/server/src/system/department-transition-safety.test.ts +148 -0
- package/template/apps/server/src/system/department-validation.ts +79 -0
- package/template/apps/server/src/system/drizzle-repository.ts +14 -0
- package/template/apps/server/src/system/drizzle-system-configs.ts +2 -0
- package/template/apps/server/src/system/identity-read-model.ts +105 -0
- package/template/apps/server/src/system/identity-role-assignment.test.ts +199 -0
- package/template/apps/server/src/system/identity-role-assignment.ts +148 -0
- package/template/apps/server/src/system/identity-route-config.ts +29 -7
- package/template/apps/server/src/system/identity-write-security.test.ts +127 -0
- package/template/apps/server/src/system/identity-write-validation.ts +170 -0
- package/template/apps/server/src/system/organization-read-security.test.ts +221 -0
- package/template/apps/server/src/system/redis-health.test.ts +1 -1
- package/template/apps/server/src/system/repository.ts +29 -2
- package/template/apps/server/src/system/resource-routes.ts +116 -72
- package/template/apps/server/src/system/route-audit.test.ts +117 -0
- package/template/apps/server/src/system/route-helpers.ts +14 -0
- package/template/apps/server/src/system/route-types.ts +11 -2
- package/template/apps/server/src/system/routes.test.ts +0 -68
- package/template/apps/server/src/system/routes.ts +29 -2
- package/template/apps/server/src/system/user-account-policy.test.ts +3 -1
- package/template/apps/server/src/system/user-account-policy.ts +26 -9
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.vue +27 -13
- package/template/apps/web/src/pages/system/SystemResourceRoutePage.webtest.ts +68 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.vue +131 -0
- package/template/apps/web/src/pages/system/components/PagedSelectionList.webtest.ts +32 -0
- package/template/apps/web/src/pages/system/components/SystemRecordDetailDialog.vue +67 -0
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.vue +114 -38
- package/template/apps/web/src/pages/system/components/SystemResourceEditor.webtest.ts +180 -0
- package/template/apps/web/src/pages/system/components/SystemResourceFilters.vue +8 -0
- package/template/apps/web/src/pages/system/components/SystemResourceForm.vue +8 -2
- package/template/apps/web/src/pages/system/components/SystemResourcePage.department.webtest.ts +89 -0
- package/template/apps/web/src/pages/system/components/SystemResourcePage.vue +42 -46
- package/template/apps/web/src/pages/system/components/SystemResourcePage.webtest.ts +12 -55
- package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +14 -2
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.vue +121 -85
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationForm.webtest.ts +57 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.vue +227 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentOrganizationTable.webtest.ts +62 -0
- package/template/apps/web/src/pages/system/components/department/DepartmentTransitionDialog.vue +72 -52
- package/template/apps/web/src/pages/system/components/role-policy/RolePermissionTree.vue +150 -79
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.vue +98 -8
- package/template/apps/web/src/pages/system/components/role-policy/RolePolicyForm.webtest.ts +108 -0
- package/template/apps/web/src/pages/system/components/selection-options.ts +71 -0
- package/template/apps/web/src/pages/system/components/selection-options.webtest.ts +49 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.vue +19 -27
- package/template/apps/web/src/pages/system/components/user-account/CreateDepartmentDialog.webtest.ts +2 -6
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.vue +128 -0
- package/template/apps/web/src/pages/system/components/user-account/CreateRoleDialog.webtest.ts +76 -0
- package/template/apps/web/src/pages/system/components/user-account/RolePermissionSummary.vue +61 -3
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.vue +144 -90
- package/template/apps/web/src/pages/system/components/user-account/UserAccountForm.webtest.ts +95 -0
- package/template/apps/web/src/pages/system/components/user-account/UserListFilters.vue +168 -0
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.vue +33 -57
- package/template/apps/web/src/pages/system/components/user-account/UserRoleSelector.webtest.ts +4 -4
- package/template/apps/web/src/pages/system/components/user-account/save-user-account.ts +4 -1
- package/template/apps/web/src/pages/system/components/user-account/user-account-draft.ts +81 -0
- package/template/apps/web/src/pages/system/composables/useDepartmentTransitions.ts +6 -10
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +72 -0
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +110 -0
- package/template/apps/web/src/pages/system/composables/useSystemResource.ts +27 -1
- package/template/apps/web/src/pages/system/resource-config.ts +2 -0
- package/template/apps/web/src/services/system-api.ts +25 -0
- package/template/docs/architecture/foundation-infrastructure.md +12 -25
- package/template/docs/development/crud-generator.md +1 -1
- package/template/docs/operations/legacy-data-migration.md +73 -0
- package/template/docs/platform/bulk-data-jobs.md +1 -1
- package/template/docs/platform/module-configuration.md +2 -2
- package/template/docs/platform/organization-membership.md +37 -6
- package/template/docs/platform/scheduled-task-executor.md +1 -1
- package/template/package.json +2 -1
- package/template/packages/config/index.test.ts +1 -5
- package/template/packages/config/package.json +2 -2
- package/template/packages/config/src/platform-boundary.test.ts +39 -0
- package/template/packages/config/src/platform.ts +0 -28
- package/template/packages/database/drizzle/0063_license_project_lifecycle.sql +28 -0
- package/template/packages/database/drizzle/meta/0063_snapshot.json +15053 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +2 -2
- package/template/packages/database/src/agent-runtime-schema.test.ts +5 -1
- package/template/packages/database/src/schema/index.ts +1 -0
- package/template/packages/database/src/schema/license-issuance-requests.ts +59 -0
- package/template/packages/database/src/schema/license-v1.ts +5 -0
- package/template/packages/jobs/package.json +2 -2
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-api-permissions.ts +14 -0
- package/template/packages/modules/src/system-features.ts +0 -11
- package/template/packages/modules/src/system-module-catalog.test.ts +4 -0
- package/template/packages/modules/src/system-module-catalog.ts +0 -1
- package/template/packages/server-sdk/index.ts +1 -49
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-runtime-readiness-exports.ts +49 -0
- package/template/packages/shared/index.ts +2 -1
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-console-capabilities.ts +163 -0
- package/template/packages/shared/src/license-console.ts +94 -0
- package/template/packages/shared/src/license.ts +5 -0
- package/template/docs/adapters/ruoyi-vue-compatibility.md +0 -93
- package/template/packages/shared/src/ruoyi.ts +0 -98
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { identityMutationResponse } from "./identity-write-validation.js";
|
|
2
|
+
import { departmentWritableFields } from "./department-validation.js";
|
|
1
3
|
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
2
4
|
import { recordMutationAudit } from "../audit/policy.js";
|
|
3
5
|
import { evaluateRouteGuards } from "../route-guards.js";
|
|
@@ -36,6 +38,14 @@ export function registerDepartmentQuickCreateRoute(
|
|
|
36
38
|
const parsed = parseDepartment(body);
|
|
37
39
|
if (parsed instanceof Response) return parsed;
|
|
38
40
|
const create = async () => {
|
|
41
|
+
const validationError = await policy?.validateInput(parsed);
|
|
42
|
+
if (validationError)
|
|
43
|
+
return Response.json(
|
|
44
|
+
typeof validationError === "string"
|
|
45
|
+
? { message: validationError }
|
|
46
|
+
: validationError,
|
|
47
|
+
{ status: 400 },
|
|
48
|
+
);
|
|
39
49
|
const policyError = await policy?.beforeCreate(parsed);
|
|
40
50
|
if (policyError) {
|
|
41
51
|
return Response.json(policyError, { status: 409 });
|
|
@@ -50,13 +60,24 @@ export function registerDepartmentQuickCreateRoute(
|
|
|
50
60
|
);
|
|
51
61
|
return item;
|
|
52
62
|
};
|
|
53
|
-
return
|
|
63
|
+
return identityMutationResponse(() =>
|
|
64
|
+
mutationRunner ? mutationRunner.run(create) : create(),
|
|
65
|
+
);
|
|
54
66
|
});
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
function parseDepartment(body: unknown): Partial<ManagedDepartment> | Response {
|
|
58
70
|
const value =
|
|
59
71
|
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
72
|
+
if (
|
|
73
|
+
Object.keys(value).some(
|
|
74
|
+
(key) => !departmentWritableFields.some((field) => field === key),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return Response.json(
|
|
78
|
+
{ error: "INVALID_FIELDS", message: "包含不可写字段" },
|
|
79
|
+
{ status: 400 },
|
|
80
|
+
);
|
|
60
81
|
const code = typeof value.code === "string" ? value.code.trim() : "";
|
|
61
82
|
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
62
83
|
if (!/^[a-zA-Z][a-zA-Z0-9_-]{1,79}$/.test(code) || !name) {
|
|
@@ -85,10 +106,10 @@ function parseDepartment(body: unknown): Partial<ManagedDepartment> | Response {
|
|
|
85
106
|
code,
|
|
86
107
|
name,
|
|
87
108
|
parentId,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
...(value.status === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: { status: value.status as ManagedDepartment["status"] }),
|
|
112
|
+
order: value.order === undefined ? 100 : (value.order as number),
|
|
92
113
|
};
|
|
93
114
|
}
|
|
94
115
|
|
|
@@ -112,35 +133,38 @@ export function registerDepartmentTransitionRoute(
|
|
|
112
133
|
if (!decision.allowed) return forbidden(decision.reason);
|
|
113
134
|
const parsed = parseTransition(body);
|
|
114
135
|
if (parsed instanceof Response) return parsed;
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
guardContext,
|
|
130
|
-
"organization.department.transition",
|
|
136
|
+
return identityMutationResponse(() =>
|
|
137
|
+
mutationRunner.run(async () => {
|
|
138
|
+
const result = await policy.transition(
|
|
139
|
+
String(params.id || ""),
|
|
140
|
+
parsed.action,
|
|
141
|
+
parsed.targetDepartmentId,
|
|
142
|
+
guardContext.actor,
|
|
143
|
+
parsed.updates,
|
|
144
|
+
);
|
|
145
|
+
if ("error" in result) {
|
|
146
|
+
return Response.json(result, { status: 409 });
|
|
147
|
+
}
|
|
148
|
+
await recordMutationAudit(
|
|
149
|
+
runtime,
|
|
131
150
|
"departments",
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
resourceEvent(
|
|
152
|
+
guardContext,
|
|
153
|
+
"organization.department.transition",
|
|
154
|
+
"departments",
|
|
155
|
+
result.department.id,
|
|
156
|
+
{
|
|
157
|
+
action: parsed.action,
|
|
158
|
+
targetDepartmentId: parsed.targetDepartmentId,
|
|
159
|
+
movedChildren: result.movedChildren,
|
|
160
|
+
movedUsers: result.movedUsers,
|
|
161
|
+
updatedRoles: result.updatedRoles,
|
|
162
|
+
},
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
return result;
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
144
168
|
});
|
|
145
169
|
}
|
|
146
170
|
|
|
@@ -148,6 +172,7 @@ function parseTransition(body: unknown):
|
|
|
148
172
|
| {
|
|
149
173
|
action: DepartmentTransitionAction;
|
|
150
174
|
targetDepartmentId?: string;
|
|
175
|
+
updates?: Partial<ManagedDepartment>;
|
|
151
176
|
}
|
|
152
177
|
| Response {
|
|
153
178
|
const value =
|
|
@@ -161,8 +186,45 @@ function parseTransition(body: unknown):
|
|
|
161
186
|
{ status: 400 },
|
|
162
187
|
);
|
|
163
188
|
}
|
|
189
|
+
if (
|
|
190
|
+
Object.keys(value).some(
|
|
191
|
+
(key) => !["action", "targetDepartmentId", "updates"].includes(key),
|
|
192
|
+
) ||
|
|
193
|
+
(value.targetDepartmentId !== undefined &&
|
|
194
|
+
typeof value.targetDepartmentId !== "string")
|
|
195
|
+
)
|
|
196
|
+
return Response.json(
|
|
197
|
+
{ error: "INVALID_DEPARTMENT_TRANSITION", message: "部门处置字段不合法" },
|
|
198
|
+
{ status: 400 },
|
|
199
|
+
);
|
|
200
|
+
if (
|
|
201
|
+
value.updates !== undefined &&
|
|
202
|
+
(!value.updates ||
|
|
203
|
+
typeof value.updates !== "object" ||
|
|
204
|
+
Array.isArray(value.updates) ||
|
|
205
|
+
Object.keys(value.updates).some(
|
|
206
|
+
(key) => !["name", "code", "order", "parentId"].includes(key),
|
|
207
|
+
))
|
|
208
|
+
)
|
|
209
|
+
return Response.json(
|
|
210
|
+
{ error: "INVALID_DEPARTMENT_TRANSITION", message: "部门编辑字段不合法" },
|
|
211
|
+
{ status: 400 },
|
|
212
|
+
);
|
|
213
|
+
if (
|
|
214
|
+
value.action === "delete" &&
|
|
215
|
+
value.updates !== undefined &&
|
|
216
|
+
Object.keys(value.updates as object).length
|
|
217
|
+
)
|
|
218
|
+
return Response.json(
|
|
219
|
+
{
|
|
220
|
+
error: "INVALID_DEPARTMENT_TRANSITION",
|
|
221
|
+
message: "删除部门时不能同时修改资料",
|
|
222
|
+
},
|
|
223
|
+
{ status: 400 },
|
|
224
|
+
);
|
|
164
225
|
return {
|
|
165
226
|
action: value.action,
|
|
227
|
+
updates: value.updates as Partial<ManagedDepartment> | undefined,
|
|
166
228
|
targetDepartmentId:
|
|
167
229
|
typeof value.targetDepartmentId === "string" && value.targetDepartmentId
|
|
168
230
|
? value.targetDepartmentId
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
3
|
+
import { createSystemRepositories } from "./seed.js";
|
|
4
|
+
import { createRouteTestRuntime } from "./route-test-runtime.js";
|
|
5
|
+
import { registerSystemRoutes } from "./routes.js";
|
|
6
|
+
import { FakeRouteApp } from "./route-test-app.js";
|
|
7
|
+
|
|
8
|
+
async function fixture() {
|
|
9
|
+
const snapshot = createFoundationSnapshot();
|
|
10
|
+
const repositories = createSystemRepositories(snapshot);
|
|
11
|
+
const runtime = createRouteTestRuntime(snapshot, {
|
|
12
|
+
WINDY_DEV_ADMIN_TOKEN: "transition-security",
|
|
13
|
+
});
|
|
14
|
+
const app = new FakeRouteApp();
|
|
15
|
+
registerSystemRoutes(app, repositories, runtime, snapshot.features);
|
|
16
|
+
const guardContext = await runtime.createGuardContext(
|
|
17
|
+
new Request("http://localhost/api/system/departments", {
|
|
18
|
+
headers: { authorization: "Bearer transition-security" },
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
await repositories.departments.create(
|
|
22
|
+
{
|
|
23
|
+
id: "source",
|
|
24
|
+
code: "source",
|
|
25
|
+
name: "原名称",
|
|
26
|
+
order: 1,
|
|
27
|
+
parentId: "dept_platform",
|
|
28
|
+
},
|
|
29
|
+
guardContext.actor,
|
|
30
|
+
);
|
|
31
|
+
await repositories.departments.create(
|
|
32
|
+
{ id: "child", code: "child", name: "下级", order: 1, parentId: "source" },
|
|
33
|
+
guardContext.actor,
|
|
34
|
+
);
|
|
35
|
+
await repositories.users.create(
|
|
36
|
+
{
|
|
37
|
+
id: "member",
|
|
38
|
+
username: "member",
|
|
39
|
+
displayName: "成员",
|
|
40
|
+
departmentId: "source",
|
|
41
|
+
roleIds: ["role_user"],
|
|
42
|
+
},
|
|
43
|
+
guardContext.actor,
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
repositories,
|
|
47
|
+
app,
|
|
48
|
+
context: { guardContext, query: {}, params: { id: "source" } },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("department mutation validation", () => {
|
|
53
|
+
test("资料编辑和停用合并请求在迁移校验失败时不保存任何部分", async () => {
|
|
54
|
+
const { app, repositories, context } = await fixture();
|
|
55
|
+
const before = {
|
|
56
|
+
departments: await repositories.departments.all(),
|
|
57
|
+
users: await repositories.users.all(),
|
|
58
|
+
};
|
|
59
|
+
const response = (await app.invoke(
|
|
60
|
+
"POST",
|
|
61
|
+
"/system/departments/:id/transition",
|
|
62
|
+
{
|
|
63
|
+
...context,
|
|
64
|
+
body: {
|
|
65
|
+
action: "disable",
|
|
66
|
+
targetDepartmentId: "child",
|
|
67
|
+
updates: { name: "新名称", order: 5 },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
)) as Response;
|
|
71
|
+
expect(response.status).toBe(409);
|
|
72
|
+
expect(await repositories.departments.all()).toEqual(before.departments);
|
|
73
|
+
expect(await repositories.users.all()).toEqual(before.users);
|
|
74
|
+
const succeeded = (await app.invoke(
|
|
75
|
+
"POST",
|
|
76
|
+
"/system/departments/:id/transition",
|
|
77
|
+
{
|
|
78
|
+
...context,
|
|
79
|
+
body: {
|
|
80
|
+
action: "disable",
|
|
81
|
+
targetDepartmentId: "dept_platform",
|
|
82
|
+
updates: { name: "新名称", order: 5 },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
)) as { department: { status: string; name: string; order: number } };
|
|
86
|
+
expect(succeeded.department).toMatchObject({
|
|
87
|
+
status: "disabled",
|
|
88
|
+
name: "新名称",
|
|
89
|
+
order: 5,
|
|
90
|
+
});
|
|
91
|
+
expect((await repositories.users.get("member"))?.departmentId).toBe(
|
|
92
|
+
"dept_platform",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("快捷创建、通用编辑和处置updates拒绝审计字段注入", async () => {
|
|
97
|
+
const { app, repositories, context } = await fixture();
|
|
98
|
+
const current = await repositories.departments.get("source");
|
|
99
|
+
for (const [method, path, body] of [
|
|
100
|
+
[
|
|
101
|
+
"POST",
|
|
102
|
+
"/system/departments/quick",
|
|
103
|
+
{
|
|
104
|
+
code: "injected",
|
|
105
|
+
name: "注入",
|
|
106
|
+
parentId: "dept_platform",
|
|
107
|
+
deletedAt: "2026-09-10T00:00:00Z",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
["PUT", "/system/departments/:id", { createdBy: "spoofed" }],
|
|
111
|
+
[
|
|
112
|
+
"POST",
|
|
113
|
+
"/system/departments/:id/transition",
|
|
114
|
+
{
|
|
115
|
+
action: "disable",
|
|
116
|
+
targetDepartmentId: "dept_platform",
|
|
117
|
+
updates: { deletedAt: "2026-09-10T00:00:00Z" },
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
"POST",
|
|
122
|
+
"/system/departments/:id/transition",
|
|
123
|
+
{
|
|
124
|
+
action: "delete",
|
|
125
|
+
targetDepartmentId: "dept_platform",
|
|
126
|
+
updates: { name: "不能静默丢弃" },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
] as const) {
|
|
130
|
+
const response = (await app.invoke(method, path, {
|
|
131
|
+
...context,
|
|
132
|
+
body,
|
|
133
|
+
})) as Response;
|
|
134
|
+
expect(response.status).toBe(400);
|
|
135
|
+
expect(await repositories.departments.get("source")).toEqual(current);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("部门排序超出PostgreSQL整数上限在写入前被拒绝", async () => {
|
|
140
|
+
const { app, repositories, context } = await fixture();
|
|
141
|
+
const response = (await app.invoke("PUT", "/system/departments/:id", {
|
|
142
|
+
...context,
|
|
143
|
+
body: { order: 2147483648 },
|
|
144
|
+
})) as Response;
|
|
145
|
+
expect(response.status).toBe(400);
|
|
146
|
+
expect((await repositories.departments.get("source"))?.order).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LifecycleStatus } from "@southwind-ai/shared";
|
|
2
|
+
import type { ManagedDepartment } from "./entities.js";
|
|
3
|
+
import type {
|
|
4
|
+
ResourcePolicyResult,
|
|
5
|
+
ResourcePolicyError,
|
|
6
|
+
} from "./route-types.js";
|
|
7
|
+
export const departmentWritableFields = [
|
|
8
|
+
"parentId",
|
|
9
|
+
"name",
|
|
10
|
+
"code",
|
|
11
|
+
"order",
|
|
12
|
+
"status",
|
|
13
|
+
] as const;
|
|
14
|
+
export async function validateDepartmentInput(
|
|
15
|
+
input: Partial<ManagedDepartment>,
|
|
16
|
+
current?: ManagedDepartment,
|
|
17
|
+
): Promise<ResourcePolicyResult> {
|
|
18
|
+
if (
|
|
19
|
+
input.code !== undefined &&
|
|
20
|
+
(typeof input.code !== "string" ||
|
|
21
|
+
!/^[a-zA-Z][a-zA-Z0-9_-]{1,79}$/.test(input.code.trim()))
|
|
22
|
+
) {
|
|
23
|
+
return invalid(
|
|
24
|
+
"INVALID_DEPARTMENT_CODE",
|
|
25
|
+
"部门编码需以字母开头且至少 2 位",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (
|
|
29
|
+
input.name !== undefined &&
|
|
30
|
+
(typeof input.name !== "string" ||
|
|
31
|
+
!input.name.trim() ||
|
|
32
|
+
input.name.trim().length > 120)
|
|
33
|
+
) {
|
|
34
|
+
return invalid("INVALID_DEPARTMENT_NAME", "部门名称不能为空");
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
input.order !== undefined &&
|
|
38
|
+
(!Number.isSafeInteger(input.order) ||
|
|
39
|
+
input.order < 0 ||
|
|
40
|
+
input.order > 2147483647)
|
|
41
|
+
) {
|
|
42
|
+
return invalid(
|
|
43
|
+
"INVALID_DEPARTMENT_ORDER",
|
|
44
|
+
"部门排序必须是 0 到 2147483647 之间的整数",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (input.status !== undefined && !isLifecycleStatus(input.status)) {
|
|
48
|
+
return invalid("INVALID_DEPARTMENT_STATUS", "部门状态不合法");
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
!current &&
|
|
52
|
+
(typeof input.code !== "string" ||
|
|
53
|
+
typeof input.name !== "string" ||
|
|
54
|
+
typeof input.parentId !== "string" ||
|
|
55
|
+
!input.parentId)
|
|
56
|
+
) {
|
|
57
|
+
return invalid(
|
|
58
|
+
"DEPARTMENT_PARENT_REQUIRED",
|
|
59
|
+
"平台只允许一个根组织,新部门必须选择上级部门",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (typeof input.name === "string") input.name = input.name.trim();
|
|
63
|
+
if (typeof input.code === "string") input.code = input.code.trim();
|
|
64
|
+
if (
|
|
65
|
+
input.parentId !== undefined &&
|
|
66
|
+
(typeof input.parentId !== "string" ||
|
|
67
|
+
!input.parentId.trim() ||
|
|
68
|
+
input.parentId.length > 64)
|
|
69
|
+
)
|
|
70
|
+
return invalid("INVALID_DEPARTMENT_PARENT", "上级部门不合法");
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function invalid(error: string, message: string): ResourcePolicyError {
|
|
75
|
+
return { error, message };
|
|
76
|
+
}
|
|
77
|
+
function isLifecycleStatus(value: unknown): value is LifecycleStatus {
|
|
78
|
+
return ["draft", "active", "disabled", "archived"].includes(String(value));
|
|
79
|
+
}
|
|
@@ -240,6 +240,20 @@ export class DrizzleEntityRepository<
|
|
|
240
240
|
if (!query.includeDeleted && deletedAt) clauses.push(isNull(deletedAt));
|
|
241
241
|
const status = this.column("status");
|
|
242
242
|
if (query.status && status) clauses.push(eq(status, query.status));
|
|
243
|
+
if (query.departmentId) {
|
|
244
|
+
const department = this.column("departmentId");
|
|
245
|
+
clauses.push(
|
|
246
|
+
department ? eq(department, query.departmentId) : sql`false`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (query.roleId) {
|
|
250
|
+
const roles = this.column("roleIds");
|
|
251
|
+
clauses.push(
|
|
252
|
+
roles
|
|
253
|
+
? sql`${roles} @> ${JSON.stringify([query.roleId])}::jsonb`
|
|
254
|
+
: sql`false`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
243
257
|
const keyword = query.q?.trim();
|
|
244
258
|
const search = keyword
|
|
245
259
|
? searchFields.map((field) => this.column(String(field))).filter(Boolean)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
2
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
3
|
+
import { evaluateRouteGuards, findSystemFeature } from "../route-guards.js";
|
|
4
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
5
|
+
import type { ManagedUser } from "./entities.js";
|
|
6
|
+
import { departmentDescendantResolver } from "../data-access/department-tree.js";
|
|
7
|
+
import { DataScopedResourceService } from "../data-access/scoped-resource.js";
|
|
8
|
+
import type { SystemRepositories } from "./seed.js";
|
|
9
|
+
import { projectMaskedUser } from "./user-projection.js";
|
|
10
|
+
|
|
11
|
+
type Runtime = ReturnType<typeof createServerRuntime>;
|
|
12
|
+
export function identityReadModel(
|
|
13
|
+
repositories: SystemRepositories,
|
|
14
|
+
runtime: Runtime,
|
|
15
|
+
features: FeatureFlagDefinition[],
|
|
16
|
+
) {
|
|
17
|
+
const canRead = async (
|
|
18
|
+
resource: "department" | "role" | "user",
|
|
19
|
+
context: RequestGuardContext,
|
|
20
|
+
) =>
|
|
21
|
+
(
|
|
22
|
+
await evaluateRouteGuards(
|
|
23
|
+
runtime,
|
|
24
|
+
context,
|
|
25
|
+
{
|
|
26
|
+
permission: {
|
|
27
|
+
method: "GET",
|
|
28
|
+
path: `/api/system/${resource === "department" ? "departments" : resource === "role" ? "roles" : "users"}`,
|
|
29
|
+
permissionKey: `system.${resource}.read`,
|
|
30
|
+
},
|
|
31
|
+
feature: findSystemFeature(features, resource),
|
|
32
|
+
},
|
|
33
|
+
{ record: false },
|
|
34
|
+
)
|
|
35
|
+
).allowed;
|
|
36
|
+
return {
|
|
37
|
+
async users(users: ManagedUser[], context: RequestGuardContext) {
|
|
38
|
+
const [readDepartments, readRoles] = await Promise.all([
|
|
39
|
+
canRead("department", context),
|
|
40
|
+
canRead("role", context),
|
|
41
|
+
]);
|
|
42
|
+
const [departments, roles] = await Promise.all([
|
|
43
|
+
readDepartments ? repositories.departments.all() : [],
|
|
44
|
+
readRoles ? repositories.roles.all() : [],
|
|
45
|
+
]);
|
|
46
|
+
const departmentNames = new Map(
|
|
47
|
+
departments.map((item) => [item.id, item.name]),
|
|
48
|
+
);
|
|
49
|
+
const roleNames = new Map(roles.map((item) => [item.id, item.name]));
|
|
50
|
+
return users.map((user) => ({
|
|
51
|
+
...projectMaskedUser(user),
|
|
52
|
+
...(readDepartments
|
|
53
|
+
? {
|
|
54
|
+
departmentName:
|
|
55
|
+
departmentNames.get(user.departmentId ?? "") ?? "未分配部门",
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
...(readRoles
|
|
59
|
+
? {
|
|
60
|
+
roleNames: user.roleIds.map(
|
|
61
|
+
(id) => roleNames.get(id) ?? "已删除角色",
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
: {}),
|
|
65
|
+
}));
|
|
66
|
+
},
|
|
67
|
+
async organization(context: RequestGuardContext) {
|
|
68
|
+
return repositories.organizationMutationRunner.run(async () => {
|
|
69
|
+
const departments = await repositories.departments.all({
|
|
70
|
+
sort: [
|
|
71
|
+
{ field: "order", order: "asc" },
|
|
72
|
+
{ field: "name", order: "asc" },
|
|
73
|
+
{ field: "id", order: "asc" },
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
const readUsers = await canRead("user", context);
|
|
77
|
+
const users = readUsers
|
|
78
|
+
? await new DataScopedResourceService(
|
|
79
|
+
repositories.users,
|
|
80
|
+
departmentDescendantResolver(repositories.departments),
|
|
81
|
+
).all({}, [], context.actor)
|
|
82
|
+
: [];
|
|
83
|
+
const children = new Map<string, number>();
|
|
84
|
+
const members = new Map<string, number>();
|
|
85
|
+
for (const item of departments)
|
|
86
|
+
if (item.parentId)
|
|
87
|
+
children.set(item.parentId, (children.get(item.parentId) ?? 0) + 1);
|
|
88
|
+
for (const user of users)
|
|
89
|
+
if (user.departmentId)
|
|
90
|
+
members.set(
|
|
91
|
+
user.departmentId,
|
|
92
|
+
(members.get(user.departmentId) ?? 0) + 1,
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
items: departments.map((item) => ({
|
|
96
|
+
...item,
|
|
97
|
+
childDepartmentCount: children.get(item.id) ?? 0,
|
|
98
|
+
...(readUsers ? { userCount: members.get(item.id) ?? 0 } : {}),
|
|
99
|
+
})),
|
|
100
|
+
total: departments.length,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|