create-windy 0.2.17 → 0.2.19

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 (40) hide show
  1. package/README.md +4 -3
  2. package/dist/cli.js +534 -76
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +11 -1
  6. package/template/README.md +8 -5
  7. package/template/apps/server/Dockerfile +3 -3
  8. package/template/apps/server/src/index.ts +8 -14
  9. package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +57 -0
  10. package/template/apps/server/src/settings/drizzle-watermark-repository.ts +62 -0
  11. package/template/apps/server/src/settings/runtime.ts +30 -3
  12. package/template/apps/server/src/settings/watermark-repository.ts +32 -0
  13. package/template/apps/server/src/settings/watermark-routes.test.ts +203 -0
  14. package/template/apps/server/src/settings/watermark-routes.ts +134 -0
  15. package/template/apps/web/runtime-server.test.ts +4 -4
  16. package/template/apps/web/src/composables/useWatermarkSettings.ts +75 -33
  17. package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +86 -0
  18. package/template/apps/web/src/composables/watermark-settings.ts +9 -35
  19. package/template/apps/web/src/composables/watermark-settings.webtest.ts +8 -32
  20. package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +81 -0
  21. package/template/apps/web/src/layout/GlobalWatermark.vue +34 -31
  22. package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +58 -7
  23. package/template/apps/web/src/pages/settings/GlobalSettingsPage.vue +1 -1
  24. package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +40 -18
  25. package/template/apps/web/src/services/platform-settings-api.ts +25 -2
  26. package/template/apps/web/vite.config.ts +3 -1
  27. package/template/docker-compose.yml +19 -6
  28. package/template/docs/platform/platform-shell-settings.md +24 -3
  29. package/template/docs/platform/web-system-crud.md +2 -2
  30. package/template/drizzle.config.ts +1 -1
  31. package/template/package.json +4 -1
  32. package/template/packages/database/drizzle/0031_panoramic_typhoid_mary.sql +12 -0
  33. package/template/packages/database/drizzle/meta/0031_snapshot.json +7387 -0
  34. package/template/packages/database/drizzle/meta/_journal.json +8 -1
  35. package/template/packages/database/src/schema/platform-settings.ts +16 -1
  36. package/template/packages/modules/src/system-api-permissions.ts +4 -0
  37. package/template/packages/modules/src/system-api-watermark.ts +26 -0
  38. package/template/packages/modules/src/system-features.ts +1 -1
  39. package/template/packages/modules/src/system-watermark-policy.test.ts +31 -0
  40. package/template/packages/shared/src/platform-settings.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "b22121438a88c70146f3e37d9819a9df15b82ab9",
4
- "generatorVersion": "0.2.17",
3
+ "templateCommit": "7b28912bcf1a776aa3328e0ea36c014cd494461f",
4
+ "generatorVersion": "0.2.19",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -31,9 +31,19 @@
31
31
  - 这是 Starter 的初始配置;项目所有者可按团队需要保留、替换或扩展。
32
32
  - 实际可用命令和提交检查以当前项目的 `package.json` 与 Git hooks 为准。
33
33
 
34
+ ## Windy 升级边界
35
+
36
+ - 业务需求优先通过自有模块和既有扩展点实现,尽量不要直接修改 Windy 提供的平台核心、
37
+ Composition Root、共享脚手架和根工具链配置,以减少后续升级冲突。
38
+ - 修改前先查看 `.windy/files.json` 的 ownership;优先扩展 `project-owned` 路径,
39
+ 对 `windy-managed` 路径仅在没有可用扩展点时做最小改动。
40
+ - 确需修改 Windy 受管代码时,使用独立小提交记录原因和影响;升级前先运行
41
+ `bun run windy update --dry-run`,根据三方合并计划处理冲突。
42
+
34
43
  ## 常用入口
35
44
 
36
- - `bun run dev`:通过 Docker Compose 启动 PostgreSQL、自动 migration、Server Web。
45
+ - `bun run dev`:复用 Docker 镜像启动 PostgreSQL、自动 migration、Server 与支持热更新的 Web。
46
+ - `bun run dev:build`:依赖、Dockerfile 或 lockfile 变化后重建并启动开发环境。
37
47
  - `bun run dev:web`:启动 Web 开发服务。
38
48
  - `bun run dev:server`:启动 Server 开发服务。
39
49
  - `bun run lint`、`bun run typecheck`、`bun run test`、`bun run build`:执行基础验证;未选择 Oxc 时没有 lint 脚本。
@@ -30,15 +30,17 @@ create-windy 已创建仅供本机使用且被 Git 忽略的 `.env`,其中两
30
30
  文件权限为 `0600`。`bun run dev` 会通过 Docker Compose 等待 PostgreSQL、执行
31
31
  Drizzle migration,再启动 Server 与 Web;不要跳过 migration 后直接把 Server 连接到
32
32
  空数据库。首次启动需要本机已经安装并启动 Docker 与 Docker Compose,并可能需要下载
33
- 基础镜像。
33
+ 基础镜像。后续 `bun run dev` 会复用已有镜像,不会重复构建;依赖、Dockerfile 或
34
+ `bun.lock` 变化后运行 `bun run dev:build`。
34
35
 
35
36
  | 服务 | 地址 |
36
37
  | ---- | ---- |
37
- | Web | http://localhost:9746 |
38
- | Server 健康检查 | http://localhost:3000/api/health |
39
- | PostgreSQL | localhost:5432 |
38
+ | Web | http://localhost:18746 |
39
+ | Server 健康检查 | http://localhost:9747/api/health |
40
+ | PostgreSQL | localhost:15432 |
40
41
 
41
- Docker Web 固定使用 `9746`。只启动单个应用时使用:
42
+ Docker Web 固定使用 `18746`,Web 与 Server 都挂载宿主机源码并保持热更新。只启动
43
+ 单个应用时使用:
42
44
 
43
45
  ```bash
44
46
  bun run dev:web
@@ -112,6 +114,7 @@ create-windy 在本机 `.env` 中随机生成的 `WINDY_BOOTSTRAP_ADMIN_PASSWORD
112
114
 
113
115
  ```bash
114
116
  bun run dev
117
+ bun run dev:build
115
118
  bun run dev:web
116
119
  bun run dev:server
117
120
  bun run typecheck
@@ -29,9 +29,9 @@ COPY packages packages
29
29
  COPY --chmod=0555 deploy/production/server-entrypoint.sh /usr/local/bin/windy-server-entrypoint
30
30
  RUN mkdir -p /app/data/audit /app/data/license && chown -R bun:bun /app/data
31
31
  ENV NODE_ENV=production
32
- ENV PORT=3000
32
+ ENV PORT=9747
33
33
  ENV WINDY_CONTAINERIZED=1
34
- EXPOSE 3000
34
+ EXPOSE 9747
35
35
  USER bun
36
- HEALTHCHECK --interval=15s --timeout=3s --start-period=15s --retries=4 CMD ["bun", "-e", "const r=await fetch('http://127.0.0.1:3000/api/ready');process.exit(r.ok?0:1)"]
36
+ HEALTHCHECK --interval=15s --timeout=3s --start-period=15s --retries=4 CMD ["bun", "-e", "const r=await fetch('http://127.0.0.1:9747/api/ready');process.exit(r.ok?0:1)"]
37
37
  CMD ["bun", "run", "--cwd", "apps/server", "start"]
@@ -53,8 +53,7 @@ import { registerSchedulerRoutes } from "./scheduler/routes.js";
53
53
  import { SchedulerWorker } from "./scheduler/worker.js";
54
54
  // @windy-module system.scheduler end
55
55
  // @windy-module system.settings begin
56
- import { registerBrandingSettingsRoutes } from "./settings/routes.js";
57
- import { createBrandingSettingsRepository } from "./settings/runtime.js";
56
+ import { createPlatformSettingsRuntime } from "./settings/runtime.js";
58
57
  // @windy-module system.settings end
59
58
  import { registerProfileRoutes } from "./profile/routes.js";
60
59
  // @windy-module system.notification begin
@@ -191,10 +190,10 @@ const storageRuntime = createStorageRuntime(
191
190
  );
192
191
  // @windy-module system.storage end
193
192
  // @windy-module system.settings begin
194
- const brandingSettingsRepository = createBrandingSettingsRepository(
195
- persistence?.db,
196
- { softwareName: platformConfig.name, logoUrl: platformConfig.logoUrl },
197
- );
193
+ const platformSettings = createPlatformSettingsRuntime(persistence?.db, {
194
+ softwareName: platformConfig.name,
195
+ logoUrl: platformConfig.logoUrl,
196
+ });
198
197
  // @windy-module system.settings end
199
198
  // @windy-module work-order begin
200
199
  const exampleModuleRuntime = createExampleModuleRuntime({
@@ -276,12 +275,7 @@ registerPlatformLicenseRoutes(app, platformLicenseRuntime);
276
275
  // @windy-module system.license end
277
276
 
278
277
  // @windy-module system.settings begin
279
- registerBrandingSettingsRoutes(
280
- app,
281
- brandingSettingsRepository,
282
- runtime,
283
- foundationSnapshot.features,
284
- );
278
+ platformSettings.register(app, runtime, foundationSnapshot.features);
285
279
  // @windy-module system.settings end
286
280
  // @windy-module system.notification begin
287
281
  registerNotificationRoutes(
@@ -304,7 +298,7 @@ registerStorageRoutes(
304
298
  uploads: storageRuntime.uploads,
305
299
  files: storageRuntime.files,
306
300
  isPublicBrandingFile: async (fileId) =>
307
- (await brandingSettingsRepository.get()).logoUrl ===
301
+ (await platformSettings.branding.get()).logoUrl ===
308
302
  `/api/platform/files/${fileId}`,
309
303
  },
310
304
  runtime,
@@ -387,7 +381,7 @@ validateServerModuleComposition({
387
381
  // @windy-module system.search end
388
382
  });
389
383
 
390
- const port = process.env.PORT ? Number(process.env.PORT) : 3000;
384
+ const port = process.env.PORT ? Number(process.env.PORT) : 9_747;
391
385
  app.listen(port);
392
386
  // @windy-module system.scheduler begin
393
387
  schedulerWorker.start();
@@ -0,0 +1,57 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { DEFAULT_PLATFORM_WATERMARK_POLICY } from "@southwind-ai/shared";
4
+ import { drizzle } from "drizzle-orm/node-postgres";
5
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
6
+ import { Pool } from "pg";
7
+ import { DrizzleWatermarkPolicyRepository } from "./drizzle-watermark-repository.js";
8
+
9
+ const databaseUrl = process.env.SETTINGS_TEST_DATABASE_URL;
10
+ const describePostgres = databaseUrl ? describe : describe.skip;
11
+
12
+ describePostgres("PostgreSQL watermark policy repository", () => {
13
+ let pool: Pool;
14
+
15
+ beforeAll(async () => {
16
+ pool = new Pool({ connectionString: databaseUrl });
17
+ const db = drizzle(pool);
18
+ await migrate(db, {
19
+ migrationsFolder: resolve(
20
+ import.meta.dir,
21
+ "../../../../packages/database/drizzle",
22
+ ),
23
+ migrationsSchema: "public",
24
+ migrationsTable: "__settings_test_migrations",
25
+ });
26
+ await pool.query("truncate table platform_watermark_policies");
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await pool?.end();
31
+ });
32
+
33
+ test("保存后可由重建的 Repository 读取同一全局策略", async () => {
34
+ const repository = new DrizzleWatermarkPolicyRepository(
35
+ drizzle(pool),
36
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
37
+ );
38
+ await repository.update(
39
+ {
40
+ enabled: true,
41
+ businessPagesEnabled: true,
42
+ customContent: "内部资料",
43
+ },
44
+ "user_settings",
45
+ );
46
+
47
+ const restarted = new DrizzleWatermarkPolicyRepository(
48
+ drizzle(pool),
49
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
50
+ );
51
+ expect(await restarted.get()).toEqual({
52
+ enabled: true,
53
+ businessPagesEnabled: true,
54
+ customContent: "内部资料",
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,62 @@
1
+ import { platformWatermarkPolicies } from "@southwind-ai/database";
2
+ import type { PlatformWatermarkPolicy } from "@southwind-ai/shared";
3
+ import { eq } from "drizzle-orm";
4
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
5
+ import type { WatermarkPolicyRepository } from "./watermark-repository.js";
6
+
7
+ const PLATFORM_WATERMARK_ID = "platform";
8
+
9
+ export class DrizzleWatermarkPolicyRepository implements WatermarkPolicyRepository {
10
+ constructor(
11
+ private readonly db: NodePgDatabase,
12
+ private readonly defaults: PlatformWatermarkPolicy,
13
+ ) {}
14
+
15
+ async get(): Promise<PlatformWatermarkPolicy> {
16
+ const [row] = await this.db
17
+ .select()
18
+ .from(platformWatermarkPolicies)
19
+ .where(eq(platformWatermarkPolicies.id, PLATFORM_WATERMARK_ID))
20
+ .limit(1);
21
+ return row ? toPolicy(row) : { ...this.defaults };
22
+ }
23
+
24
+ async update(
25
+ policy: PlatformWatermarkPolicy,
26
+ actorId: string,
27
+ ): Promise<PlatformWatermarkPolicy> {
28
+ const [row] = await this.db
29
+ .insert(platformWatermarkPolicies)
30
+ .values({
31
+ id: PLATFORM_WATERMARK_ID,
32
+ ...policy,
33
+ createdBy: actorId,
34
+ updatedBy: actorId,
35
+ })
36
+ .onConflictDoUpdate({
37
+ target: platformWatermarkPolicies.id,
38
+ set: {
39
+ enabled: policy.enabled,
40
+ businessPagesEnabled: policy.businessPagesEnabled,
41
+ customContent: policy.customContent,
42
+ updatedAt: new Date(),
43
+ updatedBy: actorId,
44
+ },
45
+ })
46
+ .returning();
47
+ if (!row) throw new Error("水印策略保存失败");
48
+ return toPolicy(row);
49
+ }
50
+ }
51
+
52
+ function toPolicy(row: {
53
+ enabled: boolean;
54
+ businessPagesEnabled: boolean;
55
+ customContent: string;
56
+ }): PlatformWatermarkPolicy {
57
+ return {
58
+ enabled: row.enabled,
59
+ businessPagesEnabled: row.businessPagesEnabled,
60
+ customContent: row.customContent,
61
+ };
62
+ }
@@ -1,13 +1,40 @@
1
- import type { PlatformBrandingSettings } from "@southwind-ai/shared";
1
+ import type {
2
+ FeatureFlagDefinition,
3
+ PlatformBrandingSettings,
4
+ } from "@southwind-ai/shared";
5
+ import { DEFAULT_PLATFORM_WATERMARK_POLICY } from "@southwind-ai/shared";
2
6
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
7
+ import type { createServerRuntime } from "../runtime.js";
8
+ import type { RouteApp } from "../system/route-types.js";
3
9
  import { DrizzleBrandingSettingsRepository } from "./drizzle-repository.js";
10
+ import { DrizzleWatermarkPolicyRepository } from "./drizzle-watermark-repository.js";
4
11
  import { InMemoryBrandingSettingsRepository } from "./repository.js";
12
+ import { registerBrandingSettingsRoutes } from "./routes.js";
13
+ import { InMemoryWatermarkPolicyRepository } from "./watermark-repository.js";
14
+ import { registerWatermarkPolicyRoutes } from "./watermark-routes.js";
5
15
 
6
- export function createBrandingSettingsRepository(
16
+ export function createPlatformSettingsRuntime(
7
17
  db: NodePgDatabase | undefined,
8
18
  defaults: PlatformBrandingSettings,
9
19
  ) {
10
- return db
20
+ const branding = db
11
21
  ? new DrizzleBrandingSettingsRepository(db, defaults)
12
22
  : new InMemoryBrandingSettingsRepository(defaults);
23
+ const watermark = db
24
+ ? new DrizzleWatermarkPolicyRepository(
25
+ db,
26
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
27
+ )
28
+ : new InMemoryWatermarkPolicyRepository();
29
+ return {
30
+ branding,
31
+ register(
32
+ app: RouteApp,
33
+ server: ReturnType<typeof createServerRuntime>,
34
+ features: FeatureFlagDefinition[],
35
+ ) {
36
+ registerBrandingSettingsRoutes(app, branding, server, features);
37
+ registerWatermarkPolicyRoutes(app, watermark, server, features);
38
+ },
39
+ };
13
40
  }
@@ -0,0 +1,32 @@
1
+ import {
2
+ DEFAULT_PLATFORM_WATERMARK_POLICY,
3
+ type PlatformWatermarkPolicy,
4
+ } from "@southwind-ai/shared";
5
+
6
+ export interface WatermarkPolicyRepository {
7
+ get(): Promise<PlatformWatermarkPolicy>;
8
+ update(
9
+ policy: PlatformWatermarkPolicy,
10
+ actorId: string,
11
+ ): Promise<PlatformWatermarkPolicy>;
12
+ }
13
+
14
+ export class InMemoryWatermarkPolicyRepository implements WatermarkPolicyRepository {
15
+ private policy: PlatformWatermarkPolicy;
16
+
17
+ constructor(defaults = DEFAULT_PLATFORM_WATERMARK_POLICY) {
18
+ this.policy = { ...defaults };
19
+ }
20
+
21
+ async get(): Promise<PlatformWatermarkPolicy> {
22
+ return { ...this.policy };
23
+ }
24
+
25
+ async update(
26
+ policy: PlatformWatermarkPolicy,
27
+ _actorId: string,
28
+ ): Promise<PlatformWatermarkPolicy> {
29
+ this.policy = { ...policy };
30
+ return this.get();
31
+ }
32
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PlatformWatermarkPolicy } from "@southwind-ai/shared";
3
+ import { createFoundationSnapshot } from "../foundation.js";
4
+ import type { RequestActor } from "../guards.js";
5
+ import { createServerRuntime } from "../runtime.js";
6
+ import type { RouteApp } from "../system/route-types.js";
7
+ import {
8
+ InMemoryWatermarkPolicyRepository,
9
+ type WatermarkPolicyRepository,
10
+ } from "./watermark-repository.js";
11
+ import { registerWatermarkPolicyRoutes } from "./watermark-routes.js";
12
+
13
+ class FakeRouteApp implements RouteApp {
14
+ readonly handlers = new Map<string, (context: unknown) => unknown>();
15
+
16
+ get(path: string, handler: (context: unknown) => unknown): void {
17
+ this.handlers.set(`GET ${path}`, handler);
18
+ }
19
+
20
+ post(): void {}
21
+
22
+ put(path: string, handler: (context: unknown) => unknown): void {
23
+ this.handlers.set(`PUT ${path}`, handler);
24
+ }
25
+
26
+ delete(): void {}
27
+
28
+ invoke(method: "GET" | "PUT", path: string, context: unknown = {}): unknown {
29
+ const handler = this.handlers.get(`${method} ${path}`);
30
+ if (!handler) throw new Error(`未注册路由 ${method} ${path}`);
31
+ return handler(context);
32
+ }
33
+ }
34
+
35
+ describe("平台水印策略路由", () => {
36
+ test("公开端点无需登录即可读取全局策略", async () => {
37
+ const fixture = await createFixture(anonymousActor());
38
+
39
+ const policy = (await fixture.app.invoke(
40
+ "GET",
41
+ "/platform/watermark-policy",
42
+ )) as PlatformWatermarkPolicy;
43
+
44
+ expect(policy).toEqual({
45
+ enabled: true,
46
+ businessPagesEnabled: false,
47
+ customContent: "",
48
+ });
49
+ });
50
+
51
+ test("管理员保存的策略对公开端点生效并写入审计", async () => {
52
+ const fixture = await createFixture(settingsActor(true));
53
+ const input: PlatformWatermarkPolicy = {
54
+ enabled: true,
55
+ businessPagesEnabled: true,
56
+ customContent: "内部资料",
57
+ };
58
+
59
+ const updated = (await fixture.app.invoke(
60
+ "PUT",
61
+ "/system/settings/watermark",
62
+ {
63
+ guardContext: fixture.guardContext,
64
+ body: input,
65
+ params: {},
66
+ query: {},
67
+ },
68
+ )) as PlatformWatermarkPolicy;
69
+ const publicPolicy = (await fixture.app.invoke(
70
+ "GET",
71
+ "/platform/watermark-policy",
72
+ )) as PlatformWatermarkPolicy;
73
+
74
+ expect(updated).toEqual(input);
75
+ expect(publicPolicy).toEqual(input);
76
+ expect(fixture.runtime.auditSink.list()).toEqual(
77
+ expect.arrayContaining([
78
+ expect.objectContaining({
79
+ action: "config.update",
80
+ target: { type: "platform-watermark", id: "platform" },
81
+ metadata: expect.objectContaining({
82
+ changedFields: ["businessPagesEnabled", "customContent"],
83
+ }),
84
+ }),
85
+ ]),
86
+ );
87
+ });
88
+
89
+ test("只读管理员可以读取策略但不能修改", async () => {
90
+ const fixture = await createFixture(settingsActor(false));
91
+
92
+ const readable = (await fixture.app.invoke(
93
+ "GET",
94
+ "/system/settings/watermark",
95
+ { guardContext: fixture.guardContext, params: {}, query: {} },
96
+ )) as PlatformWatermarkPolicy;
97
+ const denied = (await update(fixture, {
98
+ enabled: false,
99
+ businessPagesEnabled: false,
100
+ customContent: "",
101
+ })) as Response;
102
+
103
+ expect(readable.enabled).toBe(true);
104
+ expect(denied.status).toBe(403);
105
+ await expect(denied.json()).resolves.toMatchObject({
106
+ reason: "missing-permission",
107
+ });
108
+ });
109
+
110
+ test("Feature 关闭时拒绝管理读取但公开策略仍可读取", async () => {
111
+ const fixture = await createFixture(settingsActor(true), false);
112
+
113
+ const denied = (await fixture.app.invoke(
114
+ "GET",
115
+ "/system/settings/watermark",
116
+ { guardContext: fixture.guardContext, params: {}, query: {} },
117
+ )) as Response;
118
+ const publicPolicy = (await fixture.app.invoke(
119
+ "GET",
120
+ "/platform/watermark-policy",
121
+ )) as PlatformWatermarkPolicy;
122
+
123
+ expect(denied.status).toBe(403);
124
+ expect(publicPolicy.enabled).toBe(true);
125
+ });
126
+
127
+ test("非法策略返回 400 且不会覆盖已保存值", async () => {
128
+ const fixture = await createFixture(settingsActor(true));
129
+
130
+ const response = (await update(fixture, {
131
+ enabled: true,
132
+ businessPagesEnabled: true,
133
+ customContent: "x".repeat(81),
134
+ })) as Response;
135
+
136
+ expect(response.status).toBe(400);
137
+ expect(await fixture.repository.get()).toEqual({
138
+ enabled: true,
139
+ businessPagesEnabled: false,
140
+ customContent: "",
141
+ });
142
+ expect(fixture.runtime.auditSink.list()).toHaveLength(0);
143
+ });
144
+ });
145
+
146
+ async function update(
147
+ fixture: Awaited<ReturnType<typeof createFixture>>,
148
+ body: PlatformWatermarkPolicy,
149
+ ) {
150
+ return fixture.app.invoke("PUT", "/system/settings/watermark", {
151
+ guardContext: fixture.guardContext,
152
+ body,
153
+ params: {},
154
+ query: {},
155
+ });
156
+ }
157
+
158
+ async function createFixture(actor: RequestActor, featureEnabled = true) {
159
+ const snapshot = createFoundationSnapshot();
160
+ const repository: WatermarkPolicyRepository =
161
+ new InMemoryWatermarkPolicyRepository();
162
+ const runtime = createServerRuntime(
163
+ {},
164
+ {
165
+ actorResolver: { resolve: () => actor },
166
+ featureResolver: {
167
+ resolve: (key) => ({
168
+ key,
169
+ enabled: featureEnabled,
170
+ visible: "visible",
171
+ }),
172
+ },
173
+ },
174
+ );
175
+ const app = new FakeRouteApp();
176
+ registerWatermarkPolicyRoutes(app, repository, runtime, snapshot.features);
177
+ const guardContext = await runtime.createGuardContext(
178
+ new Request("http://localhost/api/system/settings/watermark"),
179
+ );
180
+ return { app, repository, runtime, guardContext };
181
+ }
182
+
183
+ function settingsActor(canManage: boolean): RequestActor {
184
+ return {
185
+ id: "user_settings",
186
+ type: "user",
187
+ name: "配置管理员",
188
+ roleCodes: ["admin"],
189
+ permissionKeys: ["system.watermark.read"].concat(
190
+ canManage ? "system.watermark.manage" : [],
191
+ ),
192
+ };
193
+ }
194
+
195
+ function anonymousActor(): RequestActor {
196
+ return {
197
+ id: "anonymous",
198
+ type: "anonymous",
199
+ name: "匿名用户",
200
+ roleCodes: [],
201
+ permissionKeys: [],
202
+ };
203
+ }
@@ -0,0 +1,134 @@
1
+ import type {
2
+ ApiPermissionBinding,
3
+ FeatureFlagDefinition,
4
+ PlatformWatermarkPolicy,
5
+ } from "@southwind-ai/shared";
6
+ import { evaluateRouteGuards, findFeature } from "../route-guards.js";
7
+ import type { createServerRuntime } from "../runtime.js";
8
+ import { resourceEvent, routeContext } from "../system/route-helpers.js";
9
+ import type { RouteApp } from "../system/route-types.js";
10
+ import type { WatermarkPolicyRepository } from "./watermark-repository.js";
11
+
12
+ type ServerRuntime = ReturnType<typeof createServerRuntime>;
13
+
14
+ const READ_BINDING = watermarkBinding("GET", "system.watermark.read");
15
+ const MANAGE_BINDING = watermarkBinding("PUT", "system.watermark.manage");
16
+
17
+ export function registerWatermarkPolicyRoutes(
18
+ app: RouteApp,
19
+ repository: WatermarkPolicyRepository,
20
+ runtime: ServerRuntime,
21
+ features: FeatureFlagDefinition[],
22
+ ): void {
23
+ const feature = findFeature(features, "system.watermark");
24
+
25
+ app.get("/platform/watermark-policy", () => repository.get());
26
+
27
+ app.get("/system/settings/watermark", async (rawContext) => {
28
+ const { guardContext } = routeContext(rawContext);
29
+ const denied = await guard(runtime, guardContext, READ_BINDING, feature);
30
+ return denied || repository.get();
31
+ });
32
+
33
+ app.put("/system/settings/watermark", async (rawContext) => {
34
+ const { body, guardContext } = routeContext(rawContext);
35
+ const denied = await guard(runtime, guardContext, MANAGE_BINDING, feature);
36
+ if (denied) return denied;
37
+
38
+ const parsed = parseWatermarkPolicy(body);
39
+ if (!parsed.ok) {
40
+ return Response.json(
41
+ { error: "INVALID_WATERMARK_POLICY", message: parsed.message },
42
+ { status: 400 },
43
+ );
44
+ }
45
+
46
+ const before = await repository.get();
47
+ const updated = await repository.update(
48
+ parsed.value,
49
+ guardContext.actor.id,
50
+ );
51
+ await runtime.auditSink.recordCriticalEvent(
52
+ resourceEvent(
53
+ guardContext,
54
+ "config.update",
55
+ "platform-watermark",
56
+ "platform",
57
+ { changedFields: changedFields(before, updated) },
58
+ ),
59
+ );
60
+ return updated;
61
+ });
62
+ }
63
+
64
+ async function guard(
65
+ runtime: ServerRuntime,
66
+ context: ReturnType<typeof routeContext>["guardContext"],
67
+ permission: ApiPermissionBinding,
68
+ feature: FeatureFlagDefinition | undefined,
69
+ ): Promise<Response | undefined> {
70
+ const decision = await evaluateRouteGuards(runtime, context, {
71
+ permission,
72
+ feature,
73
+ });
74
+ if (decision.allowed) return undefined;
75
+ return Response.json(
76
+ { error: "FORBIDDEN", reason: decision.reason },
77
+ { status: 403 },
78
+ );
79
+ }
80
+
81
+ function watermarkBinding(
82
+ method: "GET" | "PUT",
83
+ permissionKey: string,
84
+ ): ApiPermissionBinding {
85
+ return {
86
+ method,
87
+ path: "/api/system/settings/watermark",
88
+ permissionKey,
89
+ featureKey: "system.watermark",
90
+ };
91
+ }
92
+
93
+ type WatermarkParseResult =
94
+ | { ok: true; value: PlatformWatermarkPolicy }
95
+ | { ok: false; message: string };
96
+
97
+ function parseWatermarkPolicy(body: unknown): WatermarkParseResult {
98
+ if (!body || typeof body !== "object") {
99
+ return { ok: false, message: "请求体必须是对象" };
100
+ }
101
+ const source = body as Record<string, unknown>;
102
+ if (
103
+ typeof source.enabled !== "boolean" ||
104
+ typeof source.businessPagesEnabled !== "boolean"
105
+ ) {
106
+ return { ok: false, message: "水印开关必须是布尔值" };
107
+ }
108
+ if (typeof source.customContent !== "string") {
109
+ return { ok: false, message: "自定义内容必须是字符串" };
110
+ }
111
+ const customContent = source.customContent.trim();
112
+ if (customContent.length > 80) {
113
+ return { ok: false, message: "自定义内容不能超过 80 个字符" };
114
+ }
115
+ return {
116
+ ok: true,
117
+ value: {
118
+ enabled: source.enabled,
119
+ businessPagesEnabled: source.businessPagesEnabled,
120
+ customContent,
121
+ },
122
+ };
123
+ }
124
+
125
+ function changedFields(
126
+ before: PlatformWatermarkPolicy,
127
+ after: PlatformWatermarkPolicy,
128
+ ): string[] {
129
+ return ["enabled", "businessPagesEnabled", "customContent"].filter(
130
+ (field) =>
131
+ before[field as keyof PlatformWatermarkPolicy] !==
132
+ after[field as keyof PlatformWatermarkPolicy],
133
+ );
134
+ }