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.
Files changed (94) hide show
  1. package/dist/cli.js +9 -1
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +11 -0
  6. package/template/apps/agent-server/README.md +10 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
  11. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
  12. package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
  15. package/template/apps/agent-server/tests/test_api.py +16 -1
  16. package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
  17. package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
  18. package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
  19. package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
  20. package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
  21. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  22. package/template/apps/server/src/agent/run-contracts.ts +2 -0
  23. package/template/apps/server/src/agent/run-facade.test.ts +52 -2
  24. package/template/apps/server/src/agent/run-facade.ts +36 -2
  25. package/template/apps/server/src/index.ts +28 -45
  26. package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
  27. package/template/apps/server/src/module-host/initialize.ts +30 -44
  28. package/template/apps/server/src/module-host/request-context.ts +23 -17
  29. package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
  30. package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
  31. package/template/apps/server/src/module-host/route-declaration.ts +45 -0
  32. package/template/apps/server/src/module-host/route-installer.ts +27 -3
  33. package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
  34. package/template/apps/server/src/module-host/storage-port.ts +134 -1
  35. package/template/apps/server/src/module-host/storage-scope.ts +96 -0
  36. package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
  37. package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
  38. package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
  39. package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
  40. package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
  41. package/template/apps/server/src/module-host.ts +4 -0
  42. package/template/apps/server/src/runtime-development.ts +1 -0
  43. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +4 -0
  44. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +6 -0
  45. package/template/apps/server/src/settings/watermark-routes.test.ts +13 -3
  46. package/template/apps/server/src/settings/watermark-routes.ts +41 -3
  47. package/template/apps/server/src/system/built-in-roles.ts +1 -0
  48. package/template/apps/web/src/app/auth/login-page.ts +4 -0
  49. package/template/apps/web/src/composables/useLoginFlow.webtest.ts +91 -0
  50. package/template/apps/web/src/composables/useWatermarkSettings.ts +7 -3
  51. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +14 -0
  52. package/template/apps/web/src/composables/watermark-settings.ts +131 -9
  53. package/template/apps/web/src/composables/watermark-settings.webtest.ts +73 -7
  54. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +3 -1
  55. package/template/apps/web/src/layout/GlobalWatermark.vue +24 -11
  56. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +25 -11
  57. package/template/apps/web/src/pages/auth/login-page-extension.ts +9 -0
  58. package/template/apps/web/src/pages/auth/login-page-extension.webtest.ts +10 -0
  59. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +102 -4
  60. package/template/apps/web/src/router/index.ts +2 -2
  61. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +3 -2
  62. package/template/docker-compose.yml +2 -0
  63. package/template/docs/architecture/ai-egress.md +49 -7
  64. package/template/docs/architecture/ai-runtime.md +20 -1
  65. package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
  66. package/template/docs/architecture/object-storage.md +22 -2
  67. package/template/docs/development/custom-login-page.md +90 -0
  68. package/template/docs/platform/agent-runtime.md +44 -1
  69. package/template/docs/platform/business-user-directory.md +82 -0
  70. package/template/docs/platform/platform-shell-settings.md +14 -9
  71. package/template/docs/platform/web-system-crud.md +1 -1
  72. package/template/packages/database/drizzle/0034_funny_mesmero.sql +5 -0
  73. package/template/packages/database/drizzle/meta/0034_snapshot.json +7253 -0
  74. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  75. package/template/packages/database/src/schema/platform-settings.ts +13 -0
  76. package/template/packages/modules/package.json +1 -1
  77. package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
  78. package/template/packages/modules/src/ai-operation-composition.ts +115 -0
  79. package/template/packages/modules/src/manifest.ts +26 -0
  80. package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
  81. package/template/packages/modules/src/system-audit-actions.ts +5 -0
  82. package/template/packages/modules/src/system-features.ts +5 -0
  83. package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
  84. package/template/packages/modules/src/system-module-catalog.ts +26 -3
  85. package/template/packages/modules/src/system-permissions.ts +2 -0
  86. package/template/packages/server-sdk/index.ts +11 -0
  87. package/template/packages/server-sdk/package.json +3 -2
  88. package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
  89. package/template/packages/server-sdk/src/module-host.ts +8 -0
  90. package/template/packages/server-sdk/src/request-context.ts +2 -0
  91. package/template/packages/server-sdk/src/storage-port.ts +66 -0
  92. package/template/packages/server-sdk/src/tool-registration.ts +6 -0
  93. package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
  94. 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
@@ -25,6 +25,7 @@ export function developmentAdminActor(): RequestActor {
25
25
  "system.menu.read",
26
26
  "system.user.read",
27
27
  "system.user.manage",
28
+ "system.user-directory.read",
28
29
  "system.role.read",
29
30
  "system.role.manage",
30
31
  "system.department.read",
@@ -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: false,
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: ["businessPagesEnabled", "customContent"],
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: false,
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 ["enabled", "businessPagesEnabled", "customContent"].filter(
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
  }
@@ -34,6 +34,7 @@ export function createBuiltInRoleSeeds(
34
34
  ...ordinaryPermissions,
35
35
  "system.department.read",
36
36
  "system.user.read",
37
+ "system.user-directory.read",
37
38
  ].sort();
38
39
  return [
39
40
  role(
@@ -0,0 +1,4 @@
1
+ import PlatformLoginPage from "@/pages/auth/LoginPage.vue";
2
+ import { defineLoginPageComponent } from "@/pages/auth/login-page-extension";
3
+
4
+ export const loginPageComponent = defineLoginPageComponent(PlatformLoginPage);
@@ -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
+ });