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.
- package/README.md +4 -3
- package/dist/cli.js +534 -76
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +11 -1
- package/template/README.md +8 -5
- package/template/apps/server/Dockerfile +3 -3
- package/template/apps/server/src/index.ts +8 -14
- package/template/apps/server/src/settings/drizzle-watermark-repository.integration.test.ts +57 -0
- package/template/apps/server/src/settings/drizzle-watermark-repository.ts +62 -0
- package/template/apps/server/src/settings/runtime.ts +30 -3
- package/template/apps/server/src/settings/watermark-repository.ts +32 -0
- package/template/apps/server/src/settings/watermark-routes.test.ts +203 -0
- package/template/apps/server/src/settings/watermark-routes.ts +134 -0
- package/template/apps/web/runtime-server.test.ts +4 -4
- package/template/apps/web/src/composables/useWatermarkSettings.ts +75 -33
- package/template/apps/web/src/composables/useWatermarkSettings.webtest.ts +86 -0
- package/template/apps/web/src/composables/watermark-settings.ts +9 -35
- package/template/apps/web/src/composables/watermark-settings.webtest.ts +8 -32
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +81 -0
- package/template/apps/web/src/layout/GlobalWatermark.vue +34 -31
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +58 -7
- package/template/apps/web/src/pages/settings/GlobalSettingsPage.vue +1 -1
- package/template/apps/web/src/pages/settings/WatermarkSettingsSection.vue +40 -18
- package/template/apps/web/src/services/platform-settings-api.ts +25 -2
- package/template/apps/web/vite.config.ts +3 -1
- package/template/docker-compose.yml +19 -6
- package/template/docs/platform/platform-shell-settings.md +24 -3
- package/template/docs/platform/web-system-crud.md +2 -2
- package/template/drizzle.config.ts +1 -1
- package/template/package.json +4 -1
- package/template/packages/database/drizzle/0031_panoramic_typhoid_mary.sql +12 -0
- package/template/packages/database/drizzle/meta/0031_snapshot.json +7387 -0
- package/template/packages/database/drizzle/meta/_journal.json +8 -1
- package/template/packages/database/src/schema/platform-settings.ts +16 -1
- package/template/packages/modules/src/system-api-permissions.ts +4 -0
- package/template/packages/modules/src/system-api-watermark.ts +26 -0
- package/template/packages/modules/src/system-features.ts +1 -1
- package/template/packages/modules/src/system-watermark-policy.test.ts +31 -0
- package/template/packages/shared/src/platform-settings.ts +13 -0
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|
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 脚本。
|
package/template/README.md
CHANGED
|
@@ -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:
|
|
38
|
-
| Server 健康检查 | http://localhost:
|
|
39
|
-
| PostgreSQL | localhost:
|
|
38
|
+
| Web | http://localhost:18746 |
|
|
39
|
+
| Server 健康检查 | http://localhost:9747/api/health |
|
|
40
|
+
| PostgreSQL | localhost:15432 |
|
|
40
41
|
|
|
41
|
-
Docker Web 固定使用 `
|
|
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=
|
|
32
|
+
ENV PORT=9747
|
|
33
33
|
ENV WINDY_CONTAINERIZED=1
|
|
34
|
-
EXPOSE
|
|
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:
|
|
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 {
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
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) :
|
|
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 {
|
|
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
|
|
16
|
+
export function createPlatformSettingsRuntime(
|
|
7
17
|
db: NodePgDatabase | undefined,
|
|
8
18
|
defaults: PlatformBrandingSettings,
|
|
9
19
|
) {
|
|
10
|
-
|
|
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
|
+
}
|