create-windy 0.2.32 → 0.2.34
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 +9 -1
- package/package.json +1 -1
- package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +11 -0
- package/template/apps/agent-server/README.md +10 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
- package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
- package/template/apps/agent-server/tests/test_api.py +16 -1
- package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
- package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
- package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
- package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
- package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
- package/template/apps/server/src/agent/remote-runtime.ts +1 -0
- package/template/apps/server/src/agent/run-contracts.ts +2 -0
- package/template/apps/server/src/agent/run-facade.test.ts +52 -2
- package/template/apps/server/src/agent/run-facade.ts +36 -2
- package/template/apps/server/src/index.ts +28 -45
- package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
- package/template/apps/server/src/module-host/initialize.ts +30 -44
- package/template/apps/server/src/module-host/request-context.ts +23 -17
- package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
- package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
- package/template/apps/server/src/module-host/route-declaration.ts +45 -0
- package/template/apps/server/src/module-host/route-installer.ts +27 -3
- package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
- package/template/apps/server/src/module-host/storage-port.ts +134 -1
- package/template/apps/server/src/module-host/storage-scope.ts +96 -0
- package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
- package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
- package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
- package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
- package/template/apps/server/src/module-host.ts +4 -0
- package/template/apps/server/src/runtime-development.ts +1 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
- package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
- package/template/apps/server/src/settings/watermark-routes.ts +41 -3
- package/template/apps/server/src/system/built-in-roles.ts +1 -0
- package/template/apps/web/src/app/auth/login-page.ts +4 -0
- package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
- package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
- package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
- package/template/apps/web/src/composables/watermark-settings.ts +131 -9
- package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
- package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
- package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
- package/template/apps/web/src/router/index.ts +2 -2
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
- package/template/docker-compose.yml +2 -0
- package/template/docs/architecture/ai-egress.md +49 -7
- package/template/docs/architecture/ai-runtime.md +20 -1
- package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
- package/template/docs/architecture/object-storage.md +22 -2
- package/template/docs/development/custom-login-page.md +90 -0
- package/template/docs/platform/agent-runtime.md +44 -1
- package/template/docs/platform/business-user-directory.md +82 -0
- package/template/docs/platform/platform-shell-settings.md +14 -9
- package/template/docs/platform/web-system-crud.md +1 -1
- package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
- package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/src/schema/platform-settings.ts +13 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
- package/template/packages/modules/src/ai-operation-composition.ts +115 -0
- package/template/packages/modules/src/manifest.ts +26 -0
- package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
- package/template/packages/modules/src/system-audit-actions.ts +5 -0
- package/template/packages/modules/src/system-features.ts +5 -0
- package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
- package/template/packages/modules/src/system-module-catalog.ts +26 -3
- package/template/packages/modules/src/system-permissions.ts +2 -0
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +3 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
- package/template/packages/server-sdk/src/module-host.ts +8 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -0
- package/template/packages/server-sdk/src/storage-port.ts +66 -0
- package/template/packages/server-sdk/src/tool-registration.ts +6 -0
- package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
- package/template/packages/shared/src/platform-settings.ts +17 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ModuleUserDirectoryError } from "@southwind-ai/server-sdk";
|
|
3
|
+
import { buildScopeContext } from "../data-access/context.js";
|
|
4
|
+
import { createFoundationSnapshot } from "../foundation.js";
|
|
5
|
+
import type { RequestActor, RequestGuardContext } from "../guards.js";
|
|
6
|
+
import { createServerRuntime } from "../runtime.js";
|
|
7
|
+
import type { ManagedDepartment, ManagedUser } from "../system/entities.js";
|
|
8
|
+
import { createSystemRepositories } from "../system/seed.js";
|
|
9
|
+
import { createModuleUserDirectoryPort } from "./user-directory-port.js";
|
|
10
|
+
|
|
11
|
+
describe("模块用户目录 Port", () => {
|
|
12
|
+
test("只返回启用用户最小投影,并在 Repository 查询中执行部门范围", async () => {
|
|
13
|
+
const fixture = await createFixture();
|
|
14
|
+
const page = await fixture.port.list({
|
|
15
|
+
page: 1,
|
|
16
|
+
pageSize: 10,
|
|
17
|
+
q: "案",
|
|
18
|
+
...({ actorId: "forged", departmentId: "dept_other" } as object),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(page).toEqual({
|
|
22
|
+
items: [
|
|
23
|
+
{
|
|
24
|
+
id: "user_child",
|
|
25
|
+
displayName: "子部门办案员",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
total: 1,
|
|
29
|
+
page: 1,
|
|
30
|
+
pageSize: 10,
|
|
31
|
+
});
|
|
32
|
+
expect(Object.keys(page.items[0] ?? {}).sort()).toEqual([
|
|
33
|
+
"displayName",
|
|
34
|
+
"id",
|
|
35
|
+
]);
|
|
36
|
+
expect(fixture.runtime.auditSink.list()).toContainEqual(
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
action: "system.user-directory.query",
|
|
39
|
+
actor: expect.objectContaining({ id: "actor_manager" }),
|
|
40
|
+
metadata: expect.objectContaining({ resultCount: 1, total: 1 }),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("缺少权限或 Feature 停用时 fail closed 并记录拒绝", async () => {
|
|
46
|
+
const missingPermission = await createFixture({ permission: false });
|
|
47
|
+
await expect(missingPermission.port.list()).rejects.toMatchObject({
|
|
48
|
+
code: "USER_DIRECTORY_FORBIDDEN",
|
|
49
|
+
});
|
|
50
|
+
expect(missingPermission.runtime.auditSink.list()).toContainEqual(
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
action: "security.denied",
|
|
53
|
+
outcome: "denied",
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const disabledFeature = await createFixture({ featureEnabled: false });
|
|
58
|
+
await expect(disabledFeature.port.list()).rejects.toMatchObject({
|
|
59
|
+
code: "USER_DIRECTORY_FORBIDDEN",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("拒绝越界分页与搜索 Schema", async () => {
|
|
64
|
+
const fixture = await createFixture();
|
|
65
|
+
for (const query of [
|
|
66
|
+
{ page: 0 },
|
|
67
|
+
{ pageSize: 101 },
|
|
68
|
+
{ q: "x".repeat(101) },
|
|
69
|
+
{ q: 23 } as never,
|
|
70
|
+
]) {
|
|
71
|
+
await expect(fixture.port.list(query)).rejects.toBeInstanceOf(
|
|
72
|
+
ModuleUserDirectoryError,
|
|
73
|
+
);
|
|
74
|
+
await expect(fixture.port.list(query)).rejects.toMatchObject({
|
|
75
|
+
code: "USER_DIRECTORY_INVALID_QUERY",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("V1 tenant、custom 与 self 均只读取宿主 nominal ScopeContext", async () => {
|
|
81
|
+
const tenant = await createFixture({ dataScope: "tenant" });
|
|
82
|
+
expect((await tenant.port.list({ q: "办案员" })).items).toHaveLength(2);
|
|
83
|
+
|
|
84
|
+
const custom = await createFixture({ dataScope: "custom" });
|
|
85
|
+
expect((await custom.port.list({ q: "办案员" })).items).toEqual([
|
|
86
|
+
{ id: "user_child", displayName: "子部门办案员" },
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const self = await createFixture({ dataScope: "self" });
|
|
90
|
+
expect((await self.port.list({ q: "办案员" })).items).toEqual([
|
|
91
|
+
{ id: "user_child", displayName: "子部门办案员" },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("缺少宿主 nominal ScopeContext 时拒绝,不回退全量目录", async () => {
|
|
96
|
+
const fixture = await createFixture({ dataScope: "missing" });
|
|
97
|
+
await expect(fixture.port.list()).rejects.toThrow("data-scope-denied");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("生产关键审计不可持久化时不返回目录结果", async () => {
|
|
101
|
+
const fixture = await createFixture({ productionAudit: true });
|
|
102
|
+
await expect(fixture.port.list()).rejects.toThrow(
|
|
103
|
+
"关键审计事件无法持久化或进入恢复队列",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
interface FixtureOptions {
|
|
109
|
+
permission?: boolean;
|
|
110
|
+
featureEnabled?: boolean;
|
|
111
|
+
dataScope?: DirectoryScope;
|
|
112
|
+
productionAudit?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type DirectoryScope =
|
|
116
|
+
| "all"
|
|
117
|
+
| "tenant"
|
|
118
|
+
| "department-and-children"
|
|
119
|
+
| "custom"
|
|
120
|
+
| "self"
|
|
121
|
+
| "missing";
|
|
122
|
+
|
|
123
|
+
async function createFixture(options: FixtureOptions = {}) {
|
|
124
|
+
const snapshot = createFoundationSnapshot();
|
|
125
|
+
const repositories = createSystemRepositories(snapshot);
|
|
126
|
+
await seedDirectory(repositories);
|
|
127
|
+
const actor = directoryActor(
|
|
128
|
+
options.permission ?? true,
|
|
129
|
+
options.dataScope ?? "department-and-children",
|
|
130
|
+
);
|
|
131
|
+
const runtime = createServerRuntime(
|
|
132
|
+
options.productionAudit ? { NODE_ENV: "production" } : {},
|
|
133
|
+
{
|
|
134
|
+
featureCatalog: snapshot.features,
|
|
135
|
+
featureResolver: {
|
|
136
|
+
resolve: (key) => ({
|
|
137
|
+
key,
|
|
138
|
+
enabled: options.featureEnabled ?? true,
|
|
139
|
+
visible: "visible",
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
const context: RequestGuardContext = {
|
|
145
|
+
requestId: "request-directory",
|
|
146
|
+
actor,
|
|
147
|
+
startedAt: "2026-07-25T00:00:00.000Z",
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
runtime,
|
|
151
|
+
port: createModuleUserDirectoryPort(
|
|
152
|
+
{ repositories, runtime, features: snapshot.features },
|
|
153
|
+
context,
|
|
154
|
+
),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function seedDirectory(
|
|
159
|
+
repositories: ReturnType<typeof createSystemRepositories>,
|
|
160
|
+
) {
|
|
161
|
+
const system = directoryActor(true, "all");
|
|
162
|
+
for (const departmentInput of [
|
|
163
|
+
department("dept_root", "业务部门"),
|
|
164
|
+
department("dept_child", "子部门", "dept_root"),
|
|
165
|
+
department("dept_other", "其它部门"),
|
|
166
|
+
]) {
|
|
167
|
+
await repositories.departments.create(departmentInput, system);
|
|
168
|
+
}
|
|
169
|
+
for (const userInput of [
|
|
170
|
+
user("user_root", "根部门用户", "dept_root"),
|
|
171
|
+
user("user_child", "子部门办案员", "dept_child"),
|
|
172
|
+
user("user_other", "其它办案员", "dept_other"),
|
|
173
|
+
user("user_disabled", "停用办案员", "dept_child", "disabled"),
|
|
174
|
+
]) {
|
|
175
|
+
await repositories.users.create(userInput, system);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function directoryActor(
|
|
180
|
+
permission: boolean,
|
|
181
|
+
scope: DirectoryScope = "department-and-children",
|
|
182
|
+
): RequestActor {
|
|
183
|
+
const actorId = scope === "self" ? "user_child" : "actor_manager";
|
|
184
|
+
const role =
|
|
185
|
+
scope === "custom"
|
|
186
|
+
? {
|
|
187
|
+
id: "role",
|
|
188
|
+
code: "role",
|
|
189
|
+
dataScope: "custom" as const,
|
|
190
|
+
departmentIds: ["dept_child"],
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
193
|
+
id: "role",
|
|
194
|
+
code: "role",
|
|
195
|
+
dataScope: scope === "missing" ? ("self" as const) : scope,
|
|
196
|
+
departmentIds: [],
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
id: scope === "all" ? "system_seed" : actorId,
|
|
200
|
+
type: "user",
|
|
201
|
+
name: "部门负责人",
|
|
202
|
+
roleCodes: ["department-manager"],
|
|
203
|
+
permissionKeys: permission ? ["system.user-directory.read"] : [],
|
|
204
|
+
...(scope === "missing"
|
|
205
|
+
? {}
|
|
206
|
+
: {
|
|
207
|
+
scopeContext: buildScopeContext(
|
|
208
|
+
scope === "all" ? "system_seed" : actorId,
|
|
209
|
+
scope === "all" || scope === "tenant" ? undefined : "dept_root",
|
|
210
|
+
[role],
|
|
211
|
+
),
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function department(
|
|
217
|
+
id: string,
|
|
218
|
+
name: string,
|
|
219
|
+
parentId?: string,
|
|
220
|
+
): Partial<ManagedDepartment> {
|
|
221
|
+
return { id, name, code: id, order: 10, status: "active", parentId };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function user(
|
|
225
|
+
id: string,
|
|
226
|
+
displayName: string,
|
|
227
|
+
departmentId: string,
|
|
228
|
+
status: ManagedUser["status"] = "active",
|
|
229
|
+
): Partial<ManagedUser> {
|
|
230
|
+
return {
|
|
231
|
+
id,
|
|
232
|
+
username: id,
|
|
233
|
+
displayName,
|
|
234
|
+
departmentId,
|
|
235
|
+
roleIds: [],
|
|
236
|
+
status,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUIDv7 } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
ModuleUserDirectoryError,
|
|
4
|
+
type ModuleUserDirectoryPage,
|
|
5
|
+
type ModuleUserDirectoryPort,
|
|
6
|
+
type ModuleUserDirectoryQuery,
|
|
7
|
+
} from "@southwind-ai/server-sdk";
|
|
8
|
+
import type { AuditEvent, FeatureFlagDefinition } from "@southwind-ai/shared";
|
|
9
|
+
import { DataScopedResourceService } from "../data-access/scoped-resource.js";
|
|
10
|
+
import { departmentDescendantResolver } from "../data-access/department-tree.js";
|
|
11
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
12
|
+
import { evaluateRouteGuards, findFeature } from "../route-guards.js";
|
|
13
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
14
|
+
import type { SystemRepositories } from "../system/seed.js";
|
|
15
|
+
|
|
16
|
+
const featureKey = "system.user-directory";
|
|
17
|
+
const permissionKey = "system.user-directory.read";
|
|
18
|
+
const auditAction = "system.user-directory.query";
|
|
19
|
+
|
|
20
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
21
|
+
|
|
22
|
+
export interface ModuleUserDirectoryServices {
|
|
23
|
+
readonly repositories: Pick<SystemRepositories, "users" | "departments">;
|
|
24
|
+
readonly runtime: ServerRuntime;
|
|
25
|
+
readonly features: readonly FeatureFlagDefinition[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createModuleUserDirectoryPort(
|
|
29
|
+
services: ModuleUserDirectoryServices,
|
|
30
|
+
context: RequestGuardContext,
|
|
31
|
+
): ModuleUserDirectoryPort {
|
|
32
|
+
const feature = findFeature([...services.features], featureKey);
|
|
33
|
+
const access = new DataScopedResourceService(
|
|
34
|
+
services.repositories.users,
|
|
35
|
+
departmentDescendantResolver(services.repositories.departments),
|
|
36
|
+
);
|
|
37
|
+
return {
|
|
38
|
+
async list(rawQuery = {}) {
|
|
39
|
+
if (!feature) {
|
|
40
|
+
throw new ModuleUserDirectoryError(
|
|
41
|
+
"USER_DIRECTORY_UNAVAILABLE",
|
|
42
|
+
`宿主未注册 Feature:${featureKey}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const decision = await evaluateRouteGuards(services.runtime, context, {
|
|
46
|
+
permission: {
|
|
47
|
+
method: "GET",
|
|
48
|
+
path: "/internal/module-user-directory",
|
|
49
|
+
permissionKey,
|
|
50
|
+
},
|
|
51
|
+
feature,
|
|
52
|
+
});
|
|
53
|
+
if (!decision.allowed) {
|
|
54
|
+
throw new ModuleUserDirectoryError(
|
|
55
|
+
"USER_DIRECTORY_FORBIDDEN",
|
|
56
|
+
`用户目录访问被拒绝:${decision.reason}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const query = normalizeQuery(rawQuery);
|
|
60
|
+
const page = await access.list(
|
|
61
|
+
{
|
|
62
|
+
page: query.page,
|
|
63
|
+
pageSize: query.pageSize,
|
|
64
|
+
q: query.q,
|
|
65
|
+
status: "active",
|
|
66
|
+
sort: [{ field: "displayName", order: "asc" }],
|
|
67
|
+
},
|
|
68
|
+
["displayName", "username"],
|
|
69
|
+
context.actor,
|
|
70
|
+
);
|
|
71
|
+
const result: ModuleUserDirectoryPage = {
|
|
72
|
+
items: page.items.map((user) => ({
|
|
73
|
+
id: user.id,
|
|
74
|
+
displayName:
|
|
75
|
+
user.displayName?.trim() || user.username || "未命名用户",
|
|
76
|
+
})),
|
|
77
|
+
total: page.total,
|
|
78
|
+
page: page.page,
|
|
79
|
+
pageSize: page.pageSize,
|
|
80
|
+
};
|
|
81
|
+
await services.runtime.auditSink.recordCriticalEvent(
|
|
82
|
+
queryAuditEvent(context, result),
|
|
83
|
+
);
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeQuery(
|
|
90
|
+
query: ModuleUserDirectoryQuery,
|
|
91
|
+
): Required<Pick<ModuleUserDirectoryQuery, "page" | "pageSize">> &
|
|
92
|
+
Pick<ModuleUserDirectoryQuery, "q"> {
|
|
93
|
+
const page = positiveInteger(query.page ?? 1, "page", 10_000);
|
|
94
|
+
const pageSize = positiveInteger(query.pageSize ?? 20, "pageSize", 100);
|
|
95
|
+
if (query.q !== undefined && typeof query.q !== "string") {
|
|
96
|
+
invalidQuery("q 必须是字符串");
|
|
97
|
+
}
|
|
98
|
+
const q = query.q?.trim();
|
|
99
|
+
if (q && q.length > 100) {
|
|
100
|
+
invalidQuery("q 最多 100 个字符");
|
|
101
|
+
}
|
|
102
|
+
return { page, pageSize, ...(q ? { q } : {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function positiveInteger(
|
|
106
|
+
value: number,
|
|
107
|
+
field: string,
|
|
108
|
+
maximum: number,
|
|
109
|
+
): number {
|
|
110
|
+
if (!Number.isInteger(value) || value < 1 || value > maximum) {
|
|
111
|
+
invalidQuery(`${field} 必须是 1-${maximum} 的整数`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function invalidQuery(message: string): never {
|
|
117
|
+
throw new ModuleUserDirectoryError("USER_DIRECTORY_INVALID_QUERY", message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function queryAuditEvent(
|
|
121
|
+
context: RequestGuardContext,
|
|
122
|
+
result: ModuleUserDirectoryPage,
|
|
123
|
+
): AuditEvent {
|
|
124
|
+
return {
|
|
125
|
+
id: randomUUIDv7(),
|
|
126
|
+
action: auditAction,
|
|
127
|
+
outcome: "success",
|
|
128
|
+
actor: {
|
|
129
|
+
id: context.actor.id,
|
|
130
|
+
type: context.actor.type === "anonymous" ? "user" : context.actor.type,
|
|
131
|
+
name: context.actor.name,
|
|
132
|
+
},
|
|
133
|
+
target: { type: "system.user-directory", id: "enabled-users" },
|
|
134
|
+
requestId: context.requestId,
|
|
135
|
+
metadata: {
|
|
136
|
+
page: result.page,
|
|
137
|
+
pageSize: result.pageSize,
|
|
138
|
+
resultCount: result.items.length,
|
|
139
|
+
total: result.total,
|
|
140
|
+
},
|
|
141
|
+
occurredAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -33,6 +33,10 @@ export {
|
|
|
33
33
|
type ModuleStorageScope,
|
|
34
34
|
type ModuleStorageServices,
|
|
35
35
|
} from "./module-host/storage-port.js";
|
|
36
|
+
export {
|
|
37
|
+
createModuleUserDirectoryPort,
|
|
38
|
+
type ModuleUserDirectoryServices,
|
|
39
|
+
} from "./module-host/user-directory-port.js";
|
|
36
40
|
// @windy-module system.scheduler begin
|
|
37
41
|
export { createModuleTaskDefinitions } from "./module-host/task-definitions.js";
|
|
38
42
|
// @windy-module system.scheduler end
|
|
@@ -39,6 +39,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
|
|
|
39
39
|
{
|
|
40
40
|
enabled: true,
|
|
41
41
|
businessPagesEnabled: true,
|
|
42
|
+
density: "high",
|
|
43
|
+
contentFields: ["username", "phone"],
|
|
42
44
|
customContent: "内部资料",
|
|
43
45
|
},
|
|
44
46
|
"user_settings",
|
|
@@ -51,6 +53,8 @@ describePostgres("PostgreSQL watermark policy repository", () => {
|
|
|
51
53
|
expect(await restarted.get()).toEqual({
|
|
52
54
|
enabled: true,
|
|
53
55
|
businessPagesEnabled: true,
|
|
56
|
+
density: "high",
|
|
57
|
+
contentFields: ["username", "phone"],
|
|
54
58
|
customContent: "内部资料",
|
|
55
59
|
});
|
|
56
60
|
});
|
|
@@ -38,6 +38,8 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
|
|
|
38
38
|
set: {
|
|
39
39
|
enabled: policy.enabled,
|
|
40
40
|
businessPagesEnabled: policy.businessPagesEnabled,
|
|
41
|
+
density: policy.density,
|
|
42
|
+
contentFields: policy.contentFields,
|
|
41
43
|
customContent: policy.customContent,
|
|
42
44
|
updatedAt: new Date(),
|
|
43
45
|
updatedBy: actorId,
|
|
@@ -52,11 +54,15 @@ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyReposito
|
|
|
52
54
|
function toPolicy(row: {
|
|
53
55
|
enabled: boolean;
|
|
54
56
|
businessPagesEnabled: boolean;
|
|
57
|
+
density: PlatformWatermarkPolicy["density"];
|
|
58
|
+
contentFields: PlatformWatermarkPolicy["contentFields"];
|
|
55
59
|
customContent: string;
|
|
56
60
|
}): PlatformWatermarkPolicy {
|
|
57
61
|
return {
|
|
58
62
|
enabled: row.enabled,
|
|
59
63
|
businessPagesEnabled: row.businessPagesEnabled,
|
|
64
|
+
density: row.density,
|
|
65
|
+
contentFields: row.contentFields,
|
|
60
66
|
customContent: row.customContent,
|
|
61
67
|
};
|
|
62
68
|
}
|
|
@@ -43,7 +43,9 @@ describe("平台水印策略路由", () => {
|
|
|
43
43
|
|
|
44
44
|
expect(policy).toEqual({
|
|
45
45
|
enabled: true,
|
|
46
|
-
businessPagesEnabled:
|
|
46
|
+
businessPagesEnabled: true,
|
|
47
|
+
density: "medium",
|
|
48
|
+
contentFields: ["username", "displayName"],
|
|
47
49
|
customContent: "",
|
|
48
50
|
});
|
|
49
51
|
});
|
|
@@ -53,6 +55,8 @@ describe("平台水印策略路由", () => {
|
|
|
53
55
|
const input: PlatformWatermarkPolicy = {
|
|
54
56
|
enabled: true,
|
|
55
57
|
businessPagesEnabled: true,
|
|
58
|
+
density: "high",
|
|
59
|
+
contentFields: ["username", "phone", "custom"],
|
|
56
60
|
customContent: "内部资料",
|
|
57
61
|
};
|
|
58
62
|
|
|
@@ -79,7 +83,7 @@ describe("平台水印策略路由", () => {
|
|
|
79
83
|
action: "config.update",
|
|
80
84
|
target: { type: "platform-watermark", id: "platform" },
|
|
81
85
|
metadata: expect.objectContaining({
|
|
82
|
-
changedFields: ["
|
|
86
|
+
changedFields: ["density", "contentFields", "customContent"],
|
|
83
87
|
}),
|
|
84
88
|
}),
|
|
85
89
|
]),
|
|
@@ -97,6 +101,8 @@ describe("平台水印策略路由", () => {
|
|
|
97
101
|
const denied = (await update(fixture, {
|
|
98
102
|
enabled: false,
|
|
99
103
|
businessPagesEnabled: false,
|
|
104
|
+
density: "low",
|
|
105
|
+
contentFields: ["username"],
|
|
100
106
|
customContent: "",
|
|
101
107
|
})) as Response;
|
|
102
108
|
|
|
@@ -130,13 +136,17 @@ describe("平台水印策略路由", () => {
|
|
|
130
136
|
const response = (await update(fixture, {
|
|
131
137
|
enabled: true,
|
|
132
138
|
businessPagesEnabled: true,
|
|
139
|
+
density: "medium",
|
|
140
|
+
contentFields: ["username"],
|
|
133
141
|
customContent: "x".repeat(81),
|
|
134
142
|
})) as Response;
|
|
135
143
|
|
|
136
144
|
expect(response.status).toBe(400);
|
|
137
145
|
expect(await fixture.repository.get()).toEqual({
|
|
138
146
|
enabled: true,
|
|
139
|
-
businessPagesEnabled:
|
|
147
|
+
businessPagesEnabled: true,
|
|
148
|
+
density: "medium",
|
|
149
|
+
contentFields: ["username", "displayName"],
|
|
140
150
|
customContent: "",
|
|
141
151
|
});
|
|
142
152
|
expect(fixture.runtime.auditSink.list()).toHaveLength(0);
|
|
@@ -3,6 +3,10 @@ import type {
|
|
|
3
3
|
FeatureFlagDefinition,
|
|
4
4
|
PlatformWatermarkPolicy,
|
|
5
5
|
} from "@southwind-ai/shared";
|
|
6
|
+
import {
|
|
7
|
+
WATERMARK_CONTENT_FIELDS,
|
|
8
|
+
WATERMARK_DENSITIES,
|
|
9
|
+
} from "@southwind-ai/shared";
|
|
6
10
|
import { evaluateRouteGuards, findFeature } from "../route-guards.js";
|
|
7
11
|
import type { createServerRuntime } from "../runtime.js";
|
|
8
12
|
import { resourceEvent, routeContext } from "../system/route-helpers.js";
|
|
@@ -108,15 +112,43 @@ function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
|
|
|
108
112
|
if (typeof source.customContent !== "string") {
|
|
109
113
|
return { ok: false, message: "自定义内容必须是字符串" };
|
|
110
114
|
}
|
|
115
|
+
if (
|
|
116
|
+
typeof source.density !== "string" ||
|
|
117
|
+
!WATERMARK_DENSITIES.includes(
|
|
118
|
+
source.density as (typeof WATERMARK_DENSITIES)[number],
|
|
119
|
+
)
|
|
120
|
+
) {
|
|
121
|
+
return { ok: false, message: "水印密度无效" };
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!Array.isArray(source.contentFields) ||
|
|
125
|
+
source.contentFields.length === 0 ||
|
|
126
|
+
!source.contentFields.every(
|
|
127
|
+
(field) =>
|
|
128
|
+
typeof field === "string" &&
|
|
129
|
+
WATERMARK_CONTENT_FIELDS.includes(
|
|
130
|
+
field as (typeof WATERMARK_CONTENT_FIELDS)[number],
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
return { ok: false, message: "请至少选择一项有效的水印内容" };
|
|
135
|
+
}
|
|
111
136
|
const customContent = source.customContent.trim();
|
|
112
137
|
if (customContent.length > 80) {
|
|
113
138
|
return { ok: false, message: "自定义内容不能超过 80 个字符" };
|
|
114
139
|
}
|
|
140
|
+
const contentFields = [
|
|
141
|
+
...new Set(
|
|
142
|
+
source.contentFields as PlatformWatermarkPolicy["contentFields"],
|
|
143
|
+
),
|
|
144
|
+
];
|
|
115
145
|
return {
|
|
116
146
|
ok: true,
|
|
117
147
|
value: {
|
|
118
148
|
enabled: source.enabled,
|
|
119
149
|
businessPagesEnabled: source.businessPagesEnabled,
|
|
150
|
+
density: source.density as PlatformWatermarkPolicy["density"],
|
|
151
|
+
contentFields,
|
|
120
152
|
customContent,
|
|
121
153
|
},
|
|
122
154
|
};
|
|
@@ -126,9 +158,15 @@ function changedFields(
|
|
|
126
158
|
before: PlatformWatermarkPolicy,
|
|
127
159
|
after: PlatformWatermarkPolicy,
|
|
128
160
|
): string[] {
|
|
129
|
-
return [
|
|
161
|
+
return [
|
|
162
|
+
"enabled",
|
|
163
|
+
"businessPagesEnabled",
|
|
164
|
+
"density",
|
|
165
|
+
"contentFields",
|
|
166
|
+
"customContent",
|
|
167
|
+
].filter(
|
|
130
168
|
(field) =>
|
|
131
|
-
before[field as keyof PlatformWatermarkPolicy] !==
|
|
132
|
-
after[field as keyof PlatformWatermarkPolicy],
|
|
169
|
+
JSON.stringify(before[field as keyof PlatformWatermarkPolicy]) !==
|
|
170
|
+
JSON.stringify(after[field as keyof PlatformWatermarkPolicy]),
|
|
133
171
|
);
|
|
134
172
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
auth: {
|
|
5
|
+
error: { value: "" },
|
|
6
|
+
login: vi.fn(),
|
|
7
|
+
phase: { value: "anonymous" },
|
|
8
|
+
},
|
|
9
|
+
replace: vi.fn(),
|
|
10
|
+
resetAccessSnapshot: vi.fn(),
|
|
11
|
+
// @windy-module system.license begin
|
|
12
|
+
resetLicenseRuntime: vi.fn(),
|
|
13
|
+
// @windy-module system.license end
|
|
14
|
+
resetSessionContext: vi.fn(),
|
|
15
|
+
returnTo: "/admin?tab=active#top" as unknown,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("vue-router", () => ({
|
|
19
|
+
useRoute: () => ({ query: { returnTo: mocks.returnTo } }),
|
|
20
|
+
useRouter: () => ({ replace: mocks.replace }),
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
23
|
+
resetAccessSnapshot: mocks.resetAccessSnapshot,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@/composables/useAuthSession", () => ({
|
|
26
|
+
useAuthSession: () => mocks.auth,
|
|
27
|
+
}));
|
|
28
|
+
// @windy-module system.license begin
|
|
29
|
+
vi.mock("@/composables/useLicenseRuntime", () => ({
|
|
30
|
+
resetLicenseRuntime: mocks.resetLicenseRuntime,
|
|
31
|
+
}));
|
|
32
|
+
// @windy-module system.license end
|
|
33
|
+
vi.mock("@/composables/useSessionContext", () => ({
|
|
34
|
+
resetSessionContext: mocks.resetSessionContext,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { useLoginFlow } from "./useLoginFlow";
|
|
38
|
+
|
|
39
|
+
describe("登录流程扩展契约", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
mocks.auth.error.value = "";
|
|
42
|
+
mocks.auth.phase.value = "anonymous";
|
|
43
|
+
mocks.auth.login.mockReset();
|
|
44
|
+
mocks.replace.mockReset();
|
|
45
|
+
mocks.resetAccessSnapshot.mockReset();
|
|
46
|
+
// @windy-module system.license begin
|
|
47
|
+
mocks.resetLicenseRuntime.mockReset();
|
|
48
|
+
// @windy-module system.license end
|
|
49
|
+
mocks.resetSessionContext.mockReset();
|
|
50
|
+
mocks.returnTo = "/admin?tab=active#top";
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("成功登录后重置会话派生状态并进入安全 returnTo", async () => {
|
|
54
|
+
mocks.auth.login.mockResolvedValue(undefined);
|
|
55
|
+
const flow = useLoginFlow();
|
|
56
|
+
|
|
57
|
+
await flow.submit({ username: "admin", password: "secret-password" });
|
|
58
|
+
|
|
59
|
+
expect(mocks.auth.login).toHaveBeenCalledWith({
|
|
60
|
+
username: "admin",
|
|
61
|
+
password: "secret-password",
|
|
62
|
+
});
|
|
63
|
+
expect(mocks.resetAccessSnapshot).toHaveBeenCalledOnce();
|
|
64
|
+
// @windy-module system.license begin
|
|
65
|
+
expect(mocks.resetLicenseRuntime).toHaveBeenCalledOnce();
|
|
66
|
+
// @windy-module system.license end
|
|
67
|
+
expect(mocks.resetSessionContext).toHaveBeenCalledOnce();
|
|
68
|
+
expect(mocks.replace).toHaveBeenCalledWith("/admin?tab=active#top");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("业务登录页不能借 returnTo 跳转到站外", async () => {
|
|
72
|
+
mocks.returnTo = "https://evil.example/phishing";
|
|
73
|
+
mocks.auth.login.mockResolvedValue(undefined);
|
|
74
|
+
const flow = useLoginFlow();
|
|
75
|
+
|
|
76
|
+
await flow.submit({ username: "admin", password: "secret-password" });
|
|
77
|
+
|
|
78
|
+
expect(mocks.replace).toHaveBeenCalledWith("/");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("认证失败保留当前页面并暴露服务端错误", async () => {
|
|
82
|
+
mocks.auth.error.value = "用户名或密码错误";
|
|
83
|
+
mocks.auth.login.mockRejectedValue(new Error("unauthorized"));
|
|
84
|
+
const flow = useLoginFlow();
|
|
85
|
+
|
|
86
|
+
await flow.submit({ username: "admin", password: "wrong-password" });
|
|
87
|
+
|
|
88
|
+
expect(flow.submitError.value).toBe("用户名或密码错误");
|
|
89
|
+
expect(mocks.replace).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|