feat(api-watermark): 添加水印管理相关接口
This commit is contained in:
@@ -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', '分页游标无效'); }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user