diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index baba1bf..2e19644 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { AppConfigModule } from './config/app-config.module.js'; import { DATABASE, DatabaseModule } from './database/database.module.js'; import { SecurityModule } from './security/security.module.js'; import { StoresModule } from './stores/stores.module.js'; +import { WatermarksModule } from './watermarks/watermarks.module.js'; @Controller() class HealthController { @@ -41,6 +42,7 @@ class HealthController { SecurityModule, AuthModule, StoresModule, + WatermarksModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/watermarks/watermarks.controller.ts b/apps/api/src/watermarks/watermarks.controller.ts new file mode 100644 index 0000000..9d295d9 --- /dev/null +++ b/apps/api/src/watermarks/watermarks.controller.ts @@ -0,0 +1,91 @@ +import { + Body, Controller, Delete, Get, Headers, HttpCode, Inject, Param, Patch, Post, Put, Query, Req, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiCreatedResponse, ApiNoContentResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + bindStoreWatermarkRequestSchema, createWatermarkTemplateRequestSchema, + listWatermarkTemplatesQuerySchema, updateWatermarkTemplateRequestSchema, type Workspace, +} from '@atlas/contracts'; +import { z } from 'zod'; +import { ApiError } from '../common/api-error.js'; +import type { AuthenticatedRequest } from '../auth/auth.guard.js'; +import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; +import { WatermarksService } from './watermarks.service.js'; + +@ApiTags('Watermarks') +@ApiBearerAuth('access-token') +@Controller() +export class WatermarksController { + public constructor(@Inject(WatermarksService) private readonly watermarks: WatermarksService) {} + + @Get('watermark-fonts') + @ApiOperation({ summary: '读取当前渲染服务可用字体' }) + public async fonts() { return { data: await this.watermarks.fonts() }; } + + @Get('watermark-templates') + @ApiOperation({ summary: '分页读取当前工作空间活动水印模板' }) + public async list(@Query() query: unknown, @CurrentWorkspace() workspace: Workspace) { + return this.watermarks.list(workspace.id, parse(listWatermarkTemplatesQuerySchema, query)); + } + + @Get('watermark-templates/:templateId') + @ApiOperation({ summary: '读取水印模板当前版本' }) + public async get(@Param('templateId') templateId: string, @CurrentWorkspace() workspace: Workspace) { + return { data: await this.watermarks.get(workspace.id, uuid('template_id', templateId)) }; + } + + @Post('watermark-templates') + @ApiCreatedResponse({ description: '已创建水印模板及首个渲染版本' }) + public async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + return (await this.watermarks.create(workspace.id, request.auth.userId, idempotencyKey(key), + parse(createWatermarkTemplateRequestSchema, body))).body; + } + + @Patch('watermark-templates/:templateId') + @ApiOkResponse({ description: '已更新水印模板' }) + public async update(@Param('templateId') templateId: string, @Body() body: unknown, + @Headers('idempotency-key') key: string | undefined, @Headers('if-match') ifMatch: string | undefined, + @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + return (await this.watermarks.update(workspace.id, request.auth.userId, uuid('template_id', templateId), + idempotencyKey(key), revision(ifMatch), parse(updateWatermarkTemplateRequestSchema, body))).body; + } + + @Delete('watermark-templates/:templateId') + @HttpCode(204) + @ApiNoContentResponse({ description: '已软删除模板并解除当前店铺绑定' }) + public async remove(@Param('templateId') templateId: string, @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) { + await this.watermarks.remove(workspace.id, request.auth.userId, uuid('template_id', templateId), idempotencyKey(key)); + } + + @Put('stores/:storeId/watermark-binding') + @ApiOkResponse({ description: '已更新店铺推荐水印' }) + public async bindStore(@Param('storeId') storeId: string, @Body() body: unknown, + @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace) { + const input = parse(bindStoreWatermarkRequestSchema, body) as { template_id: string | null }; + return (await this.watermarks.bindStore(workspace.id, request.auth.userId, uuid('store_id', storeId), + idempotencyKey(key), input.template_id)).body; + } +} + +function parse { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { + const result = schema.safeParse(input); + if (result.success) return result.data as ReturnType extends { data: infer Data } ? Data : never; + throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', + result.error?.issues.map((issue) => ({ field: issue.path.join('.'), code: 'INVALID_FIELD', message: issue.message }))); +} +function uuid(field: string, value: string) { + if (z.uuid().safeParse(value).success) return value; + throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); +} +function idempotencyKey(value: string | undefined) { + if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); + return uuid('Idempotency-Key', value); +} +function revision(value: string | undefined) { + const match = value?.match(/^"([1-9]\d*)"$/); + if (!match) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); + return Number(match[1]); +} diff --git a/apps/api/src/watermarks/watermarks.module.ts b/apps/api/src/watermarks/watermarks.module.ts new file mode 100644 index 0000000..66eb55d --- /dev/null +++ b/apps/api/src/watermarks/watermarks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { WatermarksController } from './watermarks.controller.js'; +import { WatermarksService } from './watermarks.service.js'; + +@Module({ + controllers: [WatermarksController], + providers: [IdempotencyService, WatermarksService], +}) +export class WatermarksModule {} diff --git a/apps/api/src/watermarks/watermarks.service.ts b/apps/api/src/watermarks/watermarks.service.ts new file mode 100644 index 0000000..5a021da --- /dev/null +++ b/apps/api/src/watermarks/watermarks.service.ts @@ -0,0 +1,233 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + CreateWatermarkTemplateRequest, + ListWatermarkTemplatesQuery, + UpdateWatermarkTemplateRequest, +} from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; + +type TemplateRow = { + id: string; name: string; kind: 'text' | 'image'; current_version: number; + created_at: Date | string; updated_at: Date | string; revision: number; + text_content: string | null; image_asset_id: string | null; font_code: string | null; + font_size_px: number | null; opacity: string; scale_ratio: string | null; position: string; +}; +type TemplateConfig = Record; + +@Injectable() +export class WatermarksService { + public constructor( + @Inject(IdempotencyService) private readonly idempotency: IdempotencyService, + @Inject(DATABASE) private readonly database: Database, + ) {} + + public async fonts() { + const fonts = await this.database.query<{ code: string; display_name: string }>( + `SELECT code, display_name FROM atlas.watermark_fonts + WHERE status = 'active' ORDER BY display_name, code`, + ); + return fonts.rows; + } + + public async list(workspaceId: string, query: ListWatermarkTemplatesQuery) { + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const rows = await tx.query( + `${templateSelect} + WHERE template.status = 'active' AND template.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR (template.created_at, template.id) < ($1::timestamptz, $2::uuid)) + ORDER BY template.created_at DESC, template.id DESC LIMIT $3`, + [cursor?.createdAt ?? null, cursor?.id ?? null, query.limit + 1], + ); + const items = rows.rows.slice(0, query.limit).map(toTemplate); + const last = items.at(-1); + return { + data: items, + meta: { + has_more: rows.rows.length > query.limit, + next_cursor: rows.rows.length > query.limit && last + ? encodeCursor(last.created_at, last.id) : null, + }, + }; + }); + } + + public async get(workspaceId: string, templateId: string) { + return this.database.workspaceTransaction(workspaceId, (tx) => + this.requireTemplate(tx, templateId), + ); + } + + public async create( + workspaceId: string, userId: string, idempotencyKey: string, + input: CreateWatermarkTemplateRequest, + ) { + return this.idempotency.run( + { workspaceId, userId, method: 'POST', path: '/api/watermark-templates', key: idempotencyKey, request: input }, + async (tx) => { + await this.validateConfig(tx, input.kind, input.config); + const inserted = await tx.query<{ id: string }>( + `INSERT INTO atlas.watermark_templates (workspace_id, name, kind) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, + [input.name, input.kind], + ); + await this.insertVersion(tx, inserted.rows[0].id, 1, input.kind, input.config, userId); + return { status: 201, body: { data: await this.requireTemplate(tx, inserted.rows[0].id) } }; + }, + ); + } + + public async update( + workspaceId: string, userId: string, templateId: string, idempotencyKey: string, + revision: number, input: UpdateWatermarkTemplateRequest, + ) { + return this.idempotency.run( + { workspaceId, userId, method: 'PATCH', path: `/api/watermark-templates/${templateId}`, key: idempotencyKey, request: { if_match: revision, ...input } }, + async (tx) => { + const current = await this.lockTemplate(tx, templateId); + if (current.revision !== revision) throw versionConflict(current.revision); + const nextKind = input.kind ?? current.kind; + if (input.kind && input.kind !== current.kind && !input.config) { + throw new ApiError(422, 'VALIDATION_FAILED', '切换水印类型时必须提交新类型完整配置'); + } + // 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。 + const nextConfig = normalizeConfig(nextKind, { ...configFromRow(current), ...(input.config ?? {}) }); + await this.validateConfig(tx, nextKind, nextConfig); + const renderingChanged = input.config !== undefined || nextKind !== current.kind; + await tx.query( + `UPDATE atlas.watermark_templates SET name = COALESCE($1, name), kind = $2, + current_version = current_version + CASE WHEN $3 THEN 1 ELSE 0 END, + revision = revision + 1, updated_at = now() WHERE id = $4`, + [input.name ?? null, nextKind, renderingChanged, templateId], + ); + if (renderingChanged) { + await this.insertVersion(tx, templateId, current.current_version + 1, nextKind, nextConfig, userId); + } + return { status: 200, body: { data: await this.requireTemplate(tx, templateId) } }; + }, + ); + } + + public async remove(workspaceId: string, userId: string, templateId: string, idempotencyKey: string) { + return this.idempotency.run( + { workspaceId, userId, method: 'DELETE', path: `/api/watermark-templates/${templateId}`, key: idempotencyKey, request: {} }, + async (tx) => { + await this.lockTemplate(tx, templateId); + await tx.query(`UPDATE atlas.store_watermark_bindings SET unbound_at = now() + WHERE template_id = $1 AND unbound_at IS NULL`, [templateId]); + await tx.query(`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), + revision = revision + 1, updated_at = now() WHERE id = $1`, [templateId]); + return { status: 204, body: null }; + }, + ); + } + + public async bindStore( + workspaceId: string, userId: string, storeId: string, idempotencyKey: string, + templateId: string | null, + ) { + return this.idempotency.run( + { workspaceId, userId, method: 'PUT', path: `/api/stores/${storeId}/watermark-binding`, key: idempotencyKey, request: { template_id: templateId } }, + async (tx) => { + const store = await tx.query<{ id: string }>( + `SELECT id FROM atlas.stores WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, [storeId], + ); + if (!store.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '门店不存在'); + if (templateId) await this.requireTemplate(tx, templateId); + await tx.query(`UPDATE atlas.store_watermark_bindings SET unbound_at = now() + WHERE store_id = $1 AND unbound_at IS NULL`, [storeId]); + if (templateId) { + await tx.query(`INSERT INTO atlas.store_watermark_bindings + (workspace_id, store_id, template_id, bound_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`, + [storeId, templateId, userId]); + } + const result = templateId ? await this.requireTemplate(tx, templateId) : null; + return { status: 200, body: { data: { watermark_template: result ? { id: result.id, name: result.name } : null } } }; + }, + ); + } + + private async lockTemplate(tx: DatabaseTransaction, id: string): Promise { + const rows = await tx.query(`${templateSelect} WHERE template.id = $1 + AND template.status = 'active' AND template.deleted_at IS NULL FOR UPDATE`, [id]); + if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); + return rows.rows[0]; + } + + private async requireTemplate(tx: DatabaseTransaction, id: string) { + const rows = await tx.query(`${templateSelect} WHERE template.id = $1 + AND template.status = 'active' AND template.deleted_at IS NULL`, [id]); + if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); + return toTemplate(rows.rows[0]); + } + + private async validateConfig(tx: DatabaseTransaction, kind: 'text' | 'image', config: TemplateConfig) { + if (typeof config.opacity !== 'string' || typeof config.position !== 'string') { + throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整'); + } + if (kind === 'text') { + const fontCode = config.font_code; + if (typeof fontCode !== 'string' || typeof config.text_content !== 'string' || typeof config.font_size_px !== 'number') { + throw new ApiError(422, 'VALIDATION_FAILED', '文字水印配置不完整'); + } + const font = await tx.query(`SELECT code FROM atlas.watermark_fonts WHERE code = $1 AND status = 'active'`, [fontCode]); + if (!font.rows[0]) throw new ApiError(422, 'WATERMARK_FONT_UNAVAILABLE', '所选字体不可用'); + return; + } + const assetId = config.image_asset_id; + if (typeof assetId !== 'string' || typeof config.scale_ratio !== 'string') { + throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整'); + } + const asset = await tx.query(`SELECT id FROM atlas.media_assets WHERE id = $1`, [assetId]); + if (!asset.rows[0]) throw new ApiError(422, 'WATERMARK_IMAGE_UNAVAILABLE', '水印图片不存在或不属于当前工作空间'); + } + + private async insertVersion( + tx: DatabaseTransaction, templateId: string, version: number, kind: 'text' | 'image', + config: TemplateConfig, userId: string, + ) { + await tx.query(`INSERT INTO atlas.watermark_template_versions + (workspace_id, template_id, version, kind, text_content, image_asset_id, font_code, font_size_px, opacity, scale_ratio, position, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [templateId, version, kind, config.text_content ?? null, config.image_asset_id ?? null, + config.font_code ?? null, config.font_size_px ?? null, config.opacity, + config.scale_ratio ?? null, config.position, userId]); + } +} + +const templateSelect = `SELECT template.id, template.name, template.kind, template.current_version, + template.created_at, template.updated_at, template.revision, version.text_content, version.image_asset_id, + version.font_code, version.font_size_px, version.opacity::text AS opacity, version.scale_ratio::text AS scale_ratio, + version.position FROM atlas.watermark_templates template JOIN atlas.watermark_template_versions version + ON version.template_id = template.id AND version.version = template.current_version`; + +function toTemplate(row: TemplateRow) { + return { id: row.id, name: row.name, kind: row.kind, current_version: row.current_version, + config: configFromRow(row), created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; +} +function configFromRow(row: TemplateRow): TemplateConfig { + return row.kind === 'text' + ? { text_content: row.text_content, font_code: row.font_code, font_size_px: row.font_size_px, opacity: row.opacity, position: row.position } + : { image_asset_id: row.image_asset_id, scale_ratio: row.scale_ratio, opacity: row.opacity, position: row.position }; +} +function normalizeConfig(kind: 'text' | 'image', config: TemplateConfig): TemplateConfig { + return kind === 'text' + ? { text_content: config.text_content, font_code: config.font_code, font_size_px: config.font_size_px, opacity: config.opacity, position: config.position } + : { image_asset_id: config.image_asset_id, scale_ratio: config.scale_ratio, opacity: config.opacity, position: config.position }; +} +function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; } +function versionConflict(currentRevision: number) { + return new ApiError(409, 'VERSION_CONFLICT', '水印模板已被其他操作更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${currentRevision}`, current_revision: currentRevision }]); +} +function encodeCursor(createdAt: string, id: string) { return Buffer.from(JSON.stringify({ createdAt, id })).toString('base64url'); } +function decodeCursor(cursor: string): { createdAt: string; id: string } { + try { + const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { createdAt?: unknown; id?: unknown }; + if (typeof value.createdAt !== 'string' || Number.isNaN(Date.parse(value.createdAt)) || typeof value.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(value.id)) throw new Error(); + return { createdAt: value.createdAt, id: value.id }; + } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } +} diff --git a/apps/web/src/layouts/AuthLayout.css b/apps/web/src/layouts/AuthLayout.css index ec55e77..5364a5f 100644 --- a/apps/web/src/layouts/AuthLayout.css +++ b/apps/web/src/layouts/AuthLayout.css @@ -5,12 +5,8 @@ justify-content: center; padding: 24px; background: - <<<<<<< HEAD - radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), - radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%), - ======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), - radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%), - >>>>>>> feat-0.2.0 var(--bg-color); + radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%), + radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%); } .auth-panel { diff --git a/docs/api/README.md b/docs/api/README.md index a89e891..e0be659 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -13,7 +13,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中 | [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 | | [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 | | [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 | -| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 已设计 | 素材直传、短时访问、水印模板、字体、店铺推荐水印。 | +| [`watermarks-and-assets.md`](./watermarks-and-assets.md) | 部分已实现 | 水印模板、字体、店铺推荐水印;素材直传与短时访问待实现。 | | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | | [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 | diff --git a/docs/project/HANDOFF.md b/docs/project/HANDOFF.md index 7ab7f9a..90ff059 100644 --- a/docs/project/HANDOFF.md +++ b/docs/project/HANDOFF.md @@ -60,6 +60,12 @@ - `store_credential_verification` 任务仅在 Worker 内读取当前未撤销密钥、短暂解密并调用 Ozon `POST /v1/description-category/tree`(`{ "language": "ZH_HANS" }`)进行低风险验证。该请求已用真实店铺验证可用;仓库接口留待仓库同步模块按其实际请求契约接入。401 表示认证失败;403 单独提示账户/权限不匹配;其他确定性 4xx 记录脱敏 HTTP 状态并标为验证请求错误,不误报为凭据无效。临时网络、限流和 Ozon 服务端错误最多指数退避重试 5 次。 - 验证成功回写 `active / valid`;密钥被替换或门店删除时,旧任务取消且不得覆盖新状态。前端在验证中只显示一个标签,避免与“未验证”同时出现。 +### 1.5 开发进度(2026-09-04):水印模板 API + +- 已实现字体目录、按工作空间隔离的水印模板列表/详情/创建/编辑/软删除,以及店铺推荐水印绑定与解绑。文字/图片模板均创建不可变渲染版本;仅改名不新增版本,渲染参数或类型变化会新增版本。 +- 删除模板会解除所有当前店铺绑定,但不删除历史版本或图片资产。模板与店铺写操作均要求 `Idempotency-Key`;编辑要求 `If-Match`。 +- 图片模板可校验已有 `media_asset` 的归属;素材直传、图片校验和短时预览 URL 尚未实现,不能在前端自行拼接对象存储地址。 + ## 2. 已确认产品决策 | 主题 | 决策 | diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7ca3857..57ce411 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ export * from './collection'; export * from './auth'; export * from './stores'; +export * from './watermarks'; diff --git a/packages/contracts/src/watermarks.ts b/packages/contracts/src/watermarks.ts new file mode 100644 index 0000000..4b7fffb --- /dev/null +++ b/packages/contracts/src/watermarks.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +const uuid = z.uuid(); +const opacity = z + .string() + .regex(/^(0|1)(\.\d{1,4})?$|^0\.\d{1,4}$/, '不透明度必须是 0 到 1 的小数'); +const scaleRatio = z + .string() + .regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间'); +export const watermarkPositionSchema = z.enum([ + 'top_left', 'top_center', 'top_right', 'middle_left', 'center', + 'middle_right', 'bottom_left', 'bottom_center', 'bottom_right', +]); + +const textConfig = z.object({ + text_content: z.string().trim().min(1).max(200), + font_code: z.string().trim().min(1).max(120), + font_size_px: z.number().int().min(12).max(60), + opacity, + position: watermarkPositionSchema, +}).strict(); +const imageConfig = z.object({ + image_asset_id: uuid, + scale_ratio: scaleRatio, + opacity, + position: watermarkPositionSchema, +}).strict(); + +export const createWatermarkTemplateRequestSchema = z.object({ + name: z.string().trim().min(1).max(120), + kind: z.enum(['text', 'image']), + config: z.union([textConfig, imageConfig]), +}).strict().superRefine((value, context) => { + if (value.kind === 'text' && !('text_content' in value.config)) { + context.addIssue({ code: 'custom', path: ['config'], message: '文字水印需要完整文字配置' }); + } + if (value.kind === 'image' && !('image_asset_id' in value.config)) { + context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' }); + } +}); + +const partialConfig = z.object({ + text_content: z.string().trim().min(1).max(200).optional(), + image_asset_id: uuid.optional(), + font_code: z.string().trim().min(1).max(120).optional(), + font_size_px: z.number().int().min(12).max(60).optional(), + opacity: opacity.optional(), + scale_ratio: scaleRatio.optional(), + position: watermarkPositionSchema.optional(), +}).strict(); + +export const updateWatermarkTemplateRequestSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + kind: z.enum(['text', 'image']).optional(), + config: partialConfig.optional(), +}).strict() + .refine((value) => Object.keys(value).length > 0, { message: '至少需要提交一个可修改字段' }); + +export const listWatermarkTemplatesQuerySchema = z.object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}).strict(); + +export const bindStoreWatermarkRequestSchema = z.object({ + template_id: uuid.nullable(), +}).strict(); + +export type CreateWatermarkTemplateRequest = z.infer; +export type UpdateWatermarkTemplateRequest = z.infer; +export type ListWatermarkTemplatesQuery = z.infer; diff --git a/packages/database/migrations/006_seed_watermark_fonts.sql b/packages/database/migrations/006_seed_watermark_fonts.sql new file mode 100644 index 0000000..671dc53 --- /dev/null +++ b/packages/database/migrations/006_seed_watermark_fonts.sql @@ -0,0 +1,8 @@ +-- 首期文字水印使用随渲染运行时部署的 Noto Sans 字体。 +SET search_path TO atlas, public; + +INSERT INTO watermark_fonts (code, display_name, status) +VALUES ('NotoSans', 'Noto Sans', 'active') +ON CONFLICT (code) DO UPDATE +SET display_name = EXCLUDED.display_name, + status = 'active';