feat(api-watermark): 添加水印管理相关接口

This commit is contained in:
R524809
2026-09-04 12:45:27 +08:00
parent 70d9d5b92a
commit c2523fe349
10 changed files with 424 additions and 7 deletions
+2
View File
@@ -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],
})
@@ -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<T extends { safeParse: (input: unknown) => { 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<T['safeParse']> 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]);
}
@@ -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 {}
@@ -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<string, unknown>;
@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<TemplateRow>(
`${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<TemplateRow> {
const rows = await tx.query<TemplateRow>(`${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<TemplateRow>(`${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', '分页游标无效'); }
}