feat(api-water): 支持上传水印图片
This commit is contained in:
@@ -8,6 +8,7 @@ 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';
|
||||
import { MediaModule } from './media/media.module.js';
|
||||
|
||||
@Controller()
|
||||
class HealthController {
|
||||
@@ -43,6 +44,7 @@ class HealthController {
|
||||
AuthModule,
|
||||
StoresModule,
|
||||
WatermarksModule,
|
||||
MediaModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -28,6 +28,13 @@ const environmentSchema = z.object({
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
DATABASE_URL: z.url(),
|
||||
REDIS_URL: z.url(),
|
||||
OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'),
|
||||
OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'),
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
|
||||
OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(),
|
||||
OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(),
|
||||
MINIO_ROOT_USER: z.string().min(3).optional(),
|
||||
MINIO_ROOT_PASSWORD: z.string().min(8).optional(),
|
||||
AUTH_JWT_SECRET: z.string().min(32),
|
||||
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
|
||||
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
|
||||
@@ -94,6 +101,9 @@ export type AppConfig = Omit<
|
||||
> & {
|
||||
trustedProxyCidrs: string[];
|
||||
swaggerEnabled: boolean;
|
||||
objectStorageAccessKey: string;
|
||||
objectStorageSecretKey: string;
|
||||
objectStorageForcePathStyle: boolean;
|
||||
};
|
||||
|
||||
function validateEnvironment(
|
||||
@@ -108,6 +118,16 @@ function validateEnvironment(
|
||||
}
|
||||
|
||||
function createAppConfig(environment: Environment): AppConfig {
|
||||
const objectStorageAccessKey =
|
||||
environment.OBJECT_STORAGE_ACCESS_KEY ?? environment.MINIO_ROOT_USER;
|
||||
const objectStorageSecretKey =
|
||||
environment.OBJECT_STORAGE_SECRET_KEY ??
|
||||
environment.MINIO_ROOT_PASSWORD;
|
||||
if (!objectStorageAccessKey || !objectStorageSecretKey) {
|
||||
throw new Error(
|
||||
'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...environment,
|
||||
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
|
||||
@@ -115,6 +135,10 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
|
||||
objectStorageAccessKey,
|
||||
objectStorageSecretKey,
|
||||
objectStorageForcePathStyle:
|
||||
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
completeMediaUploadIntentRequestSchema,
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
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 { MediaService } from './media.service.js';
|
||||
|
||||
@ApiTags('Media')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller()
|
||||
export class MediaController {
|
||||
public constructor(
|
||||
@Inject(MediaService) private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
@Post('media/upload-intents')
|
||||
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
||||
public async createIntent(
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (
|
||||
await this.media.createIntent(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
idempotencyKey(key),
|
||||
parse(createMediaUploadIntentRequestSchema, body),
|
||||
)
|
||||
).body;
|
||||
}
|
||||
|
||||
@Post('media/upload-intents/:uploadId/complete')
|
||||
@ApiCreatedResponse({ description: '已完成对象安全校验并创建素材' })
|
||||
public async complete(
|
||||
@Param('uploadId') uploadId: string,
|
||||
@Body() body: unknown,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return (
|
||||
await this.media.completeIntent(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
uuid('upload_id', uploadId),
|
||||
idempotencyKey(key),
|
||||
parse(completeMediaUploadIntentRequestSchema, body),
|
||||
)
|
||||
).body;
|
||||
}
|
||||
|
||||
@Post('media-assets/:assetId/access-urls')
|
||||
@ApiOkResponse({ description: '已签发短时预览 URL' })
|
||||
public async accessUrl(
|
||||
@Param('assetId') assetId: string,
|
||||
@Body() body: unknown,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
parse(createMediaAccessUrlRequestSchema, body);
|
||||
return {
|
||||
data: await this.media.accessUrl(
|
||||
workspace.id,
|
||||
uuid('asset_id', assetId),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { MediaController } from './media.controller.js';
|
||||
import { MediaService } from './media.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [IdempotencyService, MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
@@ -0,0 +1,537 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
CompleteMediaUploadIntentRequest,
|
||||
CreateMediaUploadIntentRequest,
|
||||
} 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 { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
|
||||
type IntentRow = {
|
||||
id: string;
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
expected_content_type: string;
|
||||
expected_byte_size: string;
|
||||
expected_checksum_sha256: string;
|
||||
status: 'pending' | 'ready' | 'failed' | 'expired';
|
||||
expires_at: Date | string;
|
||||
asset_id: string | null;
|
||||
};
|
||||
type AssetRow = {
|
||||
id: string;
|
||||
content_type: string;
|
||||
byte_size: string;
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
created_at: Date | string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly s3: S3Client;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
public constructor(
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
forcePathStyle: config.objectStorageForcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.objectStorageAccessKey,
|
||||
secretAccessKey: config.objectStorageSecretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async createIntent(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
input: CreateMediaUploadIntentRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: '/api/media/upload-intents',
|
||||
key,
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
await this.ensureBucket();
|
||||
const id = randomUUID();
|
||||
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`;
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
||||
$2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
id,
|
||||
this.config.OBJECT_STORAGE_BUCKET,
|
||||
objectKey,
|
||||
input.content_type,
|
||||
input.byte_size,
|
||||
input.checksum_sha256,
|
||||
expiresAt,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new PutObjectCommand({
|
||||
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||
Key: objectKey,
|
||||
ContentType: input.content_type,
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
data: {
|
||||
upload_id: id,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
upload: {
|
||||
method: 'PUT',
|
||||
url,
|
||||
headers: { 'content-type': input.content_type },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async completeIntent(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
intentId: string,
|
||||
key: string,
|
||||
input: CompleteMediaUploadIntentRequest,
|
||||
) {
|
||||
try {
|
||||
return await this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/media/upload-intents/${intentId}/complete`,
|
||||
key,
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
const intent = await this.lockIntent(tx, intentId);
|
||||
if (intent.status === 'ready' && intent.asset_id) {
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
data: {
|
||||
asset: await this.requireAsset(
|
||||
tx,
|
||||
intent.asset_id,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (intent.status === 'failed') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'该上传意图的文件未通过安全校验,请重新上传',
|
||||
);
|
||||
}
|
||||
if (intent.status === 'expired') {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_EXPIRED',
|
||||
'上传意图已过期,请重新发起上传',
|
||||
);
|
||||
}
|
||||
if (new Date(intent.expires_at).getTime() <= Date.now()) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_EXPIRED',
|
||||
'上传意图已过期,请重新发起上传',
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.checksum_sha256 !==
|
||||
intent.expected_checksum_sha256
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_CHECKSUM_MISMATCH',
|
||||
'完成请求的文件摘要与上传意图不一致',
|
||||
);
|
||||
}
|
||||
const inspected = await this.inspectObject(intent);
|
||||
if (
|
||||
inspected.byteSize !==
|
||||
Number(intent.expected_byte_size) ||
|
||||
inspected.checksum !==
|
||||
intent.expected_checksum_sha256 ||
|
||||
inspected.contentType !== intent.expected_content_type
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'上传文件未通过安全校验',
|
||||
);
|
||||
}
|
||||
const existing = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1`,
|
||||
[inspected.checksum],
|
||||
);
|
||||
const assetId = existing.rows[0]?.id ?? randomUUID();
|
||||
if (!existing.rows[0]) {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size,
|
||||
checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
assetId,
|
||||
intent.bucket,
|
||||
intent.object_key,
|
||||
inspected.contentType,
|
||||
inspected.byteSize,
|
||||
inspected.checksum,
|
||||
inspected.width,
|
||||
inspected.height,
|
||||
],
|
||||
);
|
||||
}
|
||||
await tx.query(
|
||||
`UPDATE atlas.media_upload_intents SET status = 'ready', asset_id = $2,
|
||||
completed_at = now(), error_code = NULL, error_message = NULL WHERE id = $1`,
|
||||
[intentId, assetId],
|
||||
);
|
||||
return {
|
||||
status: 201,
|
||||
body: {
|
||||
data: {
|
||||
asset: await this.requireAsset(tx, assetId),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const code = apiErrorCode(error);
|
||||
if (
|
||||
code === 'UPLOAD_EXPIRED' ||
|
||||
code === 'UPLOAD_OBJECT_MISSING' ||
|
||||
code === 'UPLOAD_VALIDATION_FAILED'
|
||||
) {
|
||||
await this.persistTerminalIntentStatus(
|
||||
workspaceId,
|
||||
intentId,
|
||||
code,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async accessUrl(workspaceId: string, assetId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
}>(
|
||||
`SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||
const url = await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
);
|
||||
return { url, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
|
||||
private async lockIntent(
|
||||
tx: DatabaseTransaction,
|
||||
intentId: string,
|
||||
): Promise<IntentRow> {
|
||||
const rows = await tx.query<IntentRow>(
|
||||
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
|
||||
expected_checksum_sha256, status, expires_at, asset_id
|
||||
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
||||
[intentId],
|
||||
);
|
||||
if (!rows.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '上传意图不存在');
|
||||
return rows.rows[0];
|
||||
}
|
||||
|
||||
private async requireAsset(tx: DatabaseTransaction, assetId: string) {
|
||||
const rows = await tx.query<AssetRow>(
|
||||
`SELECT id, content_type, byte_size, width_px, height_px, created_at
|
||||
FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!rows.rows[0])
|
||||
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
||||
const asset = rows.rows[0];
|
||||
return {
|
||||
id: asset.id,
|
||||
content_type: asset.content_type,
|
||||
byte_size: Number(asset.byte_size),
|
||||
width_px: asset.width_px,
|
||||
height_px: asset.height_px,
|
||||
created_at:
|
||||
asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString()
|
||||
: asset.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async inspectObject(intent: IntentRow) {
|
||||
let head;
|
||||
try {
|
||||
head = await this.s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: intent.bucket,
|
||||
Key: intent.object_key,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_OBJECT_MISSING',
|
||||
'尚未找到已上传文件',
|
||||
);
|
||||
}
|
||||
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'上传文件大小不符合要求',
|
||||
);
|
||||
}
|
||||
const declaredContentType = head.ContentType?.split(
|
||||
';',
|
||||
1,
|
||||
)[0]?.toLowerCase();
|
||||
if (declaredContentType !== intent.expected_content_type) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'上传文件声明类型不符合要求',
|
||||
);
|
||||
}
|
||||
const object = await this.s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: intent.bucket,
|
||||
Key: intent.object_key,
|
||||
}),
|
||||
);
|
||||
if (!object.Body)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_OBJECT_MISSING',
|
||||
'尚未找到已上传文件',
|
||||
);
|
||||
const chunks: Buffer[] = [];
|
||||
let byteSize = 0;
|
||||
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||
byteSize += chunk.byteLength;
|
||||
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||
if (byteSize > 5 * 1024 * 1024) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'上传文件大小不符合要求',
|
||||
);
|
||||
}
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const bytes = Buffer.concat(chunks, byteSize);
|
||||
const detected = detectImage(bytes);
|
||||
if (
|
||||
!detected ||
|
||||
detected.contentType !== intent.expected_content_type
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'UPLOAD_VALIDATION_FAILED',
|
||||
'上传文件类型或内容不符合要求',
|
||||
);
|
||||
}
|
||||
return {
|
||||
contentType: detected.contentType,
|
||||
width: detected.width,
|
||||
height: detected.height,
|
||||
byteSize,
|
||||
checksum: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureBucket() {
|
||||
if (!this.bucketReady)
|
||||
this.bucketReady = (async () => {
|
||||
try {
|
||||
await this.s3.send(
|
||||
new HeadBucketCommand({
|
||||
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
await this.s3.send(
|
||||
new CreateBucketCommand({
|
||||
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
return this.bucketReady;
|
||||
}
|
||||
|
||||
private async persistTerminalIntentStatus(
|
||||
workspaceId: string,
|
||||
intentId: string,
|
||||
code:
|
||||
| 'UPLOAD_EXPIRED'
|
||||
| 'UPLOAD_OBJECT_MISSING'
|
||||
| 'UPLOAD_VALIDATION_FAILED',
|
||||
) {
|
||||
await this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const expired = code === 'UPLOAD_EXPIRED';
|
||||
await tx.query(
|
||||
`UPDATE atlas.media_upload_intents
|
||||
SET status = $2, error_code = $3, error_message = $4, completed_at = now()
|
||||
WHERE id = $1 AND status = 'pending'`,
|
||||
[
|
||||
intentId,
|
||||
expired ? 'expired' : 'failed',
|
||||
code,
|
||||
expired ? '上传意图已过期' : '上传文件未通过安全校验',
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function apiErrorCode(error: unknown): string | undefined {
|
||||
if (!(error instanceof ApiError)) return undefined;
|
||||
const response = error.getResponse();
|
||||
if (typeof response !== 'object' || !response || !('error' in response))
|
||||
return undefined;
|
||||
const apiError = response.error;
|
||||
return typeof apiError === 'object' &&
|
||||
apiError &&
|
||||
'code' in apiError &&
|
||||
typeof apiError.code === 'string'
|
||||
? apiError.code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function detectImage(bytes: Buffer): {
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
|
||||
width: number;
|
||||
height: number;
|
||||
} | null {
|
||||
if (
|
||||
bytes.length >= 24 &&
|
||||
bytes
|
||||
.subarray(0, 8)
|
||||
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
||||
) {
|
||||
return dimensions(
|
||||
'image/png',
|
||||
bytes.readUInt32BE(16),
|
||||
bytes.readUInt32BE(20),
|
||||
);
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes.subarray(0, 4).toString() === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString() === 'WEBP'
|
||||
) {
|
||||
const type = bytes.subarray(12, 16).toString();
|
||||
if (type === 'VP8X' && bytes.length >= 30) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
1 + bytes.readUIntLE(24, 3),
|
||||
1 + bytes.readUIntLE(27, 3),
|
||||
);
|
||||
}
|
||||
if (
|
||||
type === 'VP8 ' &&
|
||||
bytes.length >= 30 &&
|
||||
bytes[23] === 0x9d &&
|
||||
bytes[24] === 0x01 &&
|
||||
bytes[25] === 0x2a
|
||||
) {
|
||||
return dimensions(
|
||||
'image/webp',
|
||||
bytes.readUInt16LE(26) & 0x3fff,
|
||||
bytes.readUInt16LE(28) & 0x3fff,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
for (let offset = 2; offset + 9 < bytes.length;) {
|
||||
if (bytes[offset] !== 0xff) return null;
|
||||
const marker = bytes[offset + 1];
|
||||
const length = bytes.readUInt16BE(offset + 2);
|
||||
if (length < 2 || offset + 2 + length > bytes.length) return null;
|
||||
if (
|
||||
[
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
|
||||
0xcd, 0xce, 0xcf,
|
||||
].includes(marker)
|
||||
) {
|
||||
return dimensions(
|
||||
'image/jpeg',
|
||||
bytes.readUInt16BE(offset + 7),
|
||||
bytes.readUInt16BE(offset + 5),
|
||||
);
|
||||
}
|
||||
offset += 2 + length;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function dimensions(
|
||||
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
|
||||
? { contentType, width, height }
|
||||
: null;
|
||||
}
|
||||
@@ -10,22 +10,36 @@ 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;
|
||||
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(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 }>(
|
||||
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`,
|
||||
);
|
||||
@@ -40,7 +54,11 @@ export class WatermarksService {
|
||||
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],
|
||||
[
|
||||
cursor?.createdAt ?? null,
|
||||
cursor?.id ?? null,
|
||||
query.limit + 1,
|
||||
],
|
||||
);
|
||||
const items = rows.rows.slice(0, query.limit).map(toTemplate);
|
||||
const last = items.at(-1);
|
||||
@@ -48,8 +66,10 @@ export class WatermarksService {
|
||||
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,
|
||||
next_cursor:
|
||||
rows.rows.length > query.limit && last
|
||||
? encodeCursor(last.created_at, last.id)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -62,11 +82,20 @@ export class WatermarksService {
|
||||
}
|
||||
|
||||
public async create(
|
||||
workspaceId: string, userId: string, idempotencyKey: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
input: CreateWatermarkTemplateRequest,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{ workspaceId, userId, method: 'POST', path: '/api/watermark-templates', key: idempotencyKey, request: input },
|
||||
{
|
||||
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 }>(
|
||||
@@ -74,128 +103,290 @@ export class WatermarksService {
|
||||
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) } };
|
||||
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,
|
||||
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 } },
|
||||
{
|
||||
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);
|
||||
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', '切换水印类型时必须提交新类型完整配置');
|
||||
if (
|
||||
input.kind &&
|
||||
input.kind !== current.kind &&
|
||||
!input.config
|
||||
) {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'切换水印类型时必须提交新类型完整配置',
|
||||
);
|
||||
}
|
||||
// 同类型编辑允许只提交变化字段;切换类型时旧类型字段不会通过新类型校验。
|
||||
const nextConfig = normalizeConfig(nextKind, { ...configFromRow(current), ...(input.config ?? {}) });
|
||||
const nextConfig = normalizeConfig(nextKind, {
|
||||
...configFromRow(current),
|
||||
...(input.config ?? {}),
|
||||
});
|
||||
await this.validateConfig(tx, nextKind, nextConfig);
|
||||
const renderingChanged = input.config !== undefined || nextKind !== current.kind;
|
||||
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],
|
||||
[
|
||||
input.name ?? null,
|
||||
nextKind,
|
||||
renderingChanged,
|
||||
templateId,
|
||||
],
|
||||
);
|
||||
if (renderingChanged) {
|
||||
await this.insertVersion(tx, templateId, current.current_version + 1, nextKind, nextConfig, userId);
|
||||
await this.insertVersion(
|
||||
tx,
|
||||
templateId,
|
||||
current.current_version + 1,
|
||||
nextKind,
|
||||
nextConfig,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
return { status: 200, body: { data: await this.requireTemplate(tx, templateId) } };
|
||||
return {
|
||||
status: 200,
|
||||
body: { data: await this.requireTemplate(tx, templateId) },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async remove(workspaceId: string, userId: string, templateId: string, idempotencyKey: string) {
|
||||
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: {} },
|
||||
{
|
||||
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]);
|
||||
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,
|
||||
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 } },
|
||||
{
|
||||
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],
|
||||
`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 (!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]);
|
||||
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
|
||||
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]);
|
||||
[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 } } };
|
||||
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', '水印模板不存在');
|
||||
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', '水印模板不存在');
|
||||
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') {
|
||||
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', '文字水印配置不完整');
|
||||
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', '所选字体不可用');
|
||||
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') {
|
||||
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', '水印图片不存在或不属于当前工作空间');
|
||||
const asset = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.media_assets WHERE id = $1
|
||||
AND content_type IN ('image/png', 'image/jpeg', 'image/webp')`,
|
||||
[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,
|
||||
tx: DatabaseTransaction,
|
||||
templateId: string,
|
||||
version: number,
|
||||
kind: 'text' | 'image',
|
||||
config: TemplateConfig,
|
||||
userId: string,
|
||||
) {
|
||||
await tx.query(`INSERT INTO atlas.watermark_template_versions
|
||||
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]);
|
||||
[
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,28 +397,82 @@ const templateSelect = `SELECT template.id, template.name, template.kind, templa
|
||||
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 };
|
||||
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 };
|
||||
? {
|
||||
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 {
|
||||
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 };
|
||||
? {
|
||||
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 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 }]);
|
||||
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 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();
|
||||
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', '分页游标无效'); }
|
||||
} catch {
|
||||
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user