feat(api-water): 支持上传水印图片

This commit is contained in:
R524809
2026-09-04 14:27:59 +08:00
parent c2523fe349
commit 009082d0f4
15 changed files with 6484 additions and 6089 deletions
+8
View File
@@ -62,6 +62,14 @@ LOG_LEVEL=log
# Redis endpoint used by BullMQ and shared authentication rate limits. # Redis endpoint used by BullMQ and shared authentication rate limits.
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# S3-compatible object storage. Local development falls back to MinIO root credentials above;
# production must set dedicated, least-privileged OBJECT_STORAGE_ACCESS_KEY / SECRET_KEY values.
OBJECT_STORAGE_ENDPOINT=http://localhost:9000
OBJECT_STORAGE_BUCKET=atlas-assets
OBJECT_STORAGE_FORCE_PATH_STYLE=true
# OBJECT_STORAGE_ACCESS_KEY=
# OBJECT_STORAGE_SECRET_KEY=
# Local MinIO root account; it is only for Docker bootstrap, not browser credentials. # Local MinIO root account; it is only for Docker bootstrap, not browser credentials.
MINIO_ROOT_USER=atlas_minio_local MINIO_ROOT_USER=atlas_minio_local
# Local MinIO root password; replace outside disposable local development. # Local MinIO root password; replace outside disposable local development.
+2
View File
@@ -13,6 +13,8 @@
"dependencies": { "dependencies": {
"@atlas/contracts": "workspace:*", "@atlas/contracts": "workspace:*",
"@atlas/database": "workspace:*", "@atlas/database": "workspace:*",
"@aws-sdk/client-s3": "3.1015.0",
"@aws-sdk/s3-request-presigner": "3.1015.0",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.0",
"@nestjs/core": "12.0.1", "@nestjs/core": "12.0.1",
+2
View File
@@ -8,6 +8,7 @@ import { DATABASE, DatabaseModule } from './database/database.module.js';
import { SecurityModule } from './security/security.module.js'; import { SecurityModule } from './security/security.module.js';
import { StoresModule } from './stores/stores.module.js'; import { StoresModule } from './stores/stores.module.js';
import { WatermarksModule } from './watermarks/watermarks.module.js'; import { WatermarksModule } from './watermarks/watermarks.module.js';
import { MediaModule } from './media/media.module.js';
@Controller() @Controller()
class HealthController { class HealthController {
@@ -43,6 +44,7 @@ class HealthController {
AuthModule, AuthModule,
StoresModule, StoresModule,
WatermarksModule, WatermarksModule,
MediaModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+24
View File
@@ -28,6 +28,13 @@ const environmentSchema = z.object({
WEB_ORIGIN: z.url().default('http://localhost:8100'), WEB_ORIGIN: z.url().default('http://localhost:8100'),
DATABASE_URL: z.url(), DATABASE_URL: z.url(),
REDIS_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_JWT_SECRET: z.string().min(32),
AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32), AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32),
STORE_SECRET_ENCRYPTION_KEY: z.string().min(32), STORE_SECRET_ENCRYPTION_KEY: z.string().min(32),
@@ -94,6 +101,9 @@ export type AppConfig = Omit<
> & { > & {
trustedProxyCidrs: string[]; trustedProxyCidrs: string[];
swaggerEnabled: boolean; swaggerEnabled: boolean;
objectStorageAccessKey: string;
objectStorageSecretKey: string;
objectStorageForcePathStyle: boolean;
}; };
function validateEnvironment( function validateEnvironment(
@@ -108,6 +118,16 @@ function validateEnvironment(
} }
function createAppConfig(environment: Environment): AppConfig { 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 { return {
...environment, ...environment,
// 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。 // 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。
@@ -115,6 +135,10 @@ function createAppConfig(environment: Environment): AppConfig {
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean), .filter(Boolean),
swaggerEnabled: environment.SWAGGER_ENABLED === 'true', swaggerEnabled: environment.SWAGGER_ENABLED === 'true',
objectStorageAccessKey,
objectStorageSecretKey,
objectStorageForcePathStyle:
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
}; };
} }
+132
View File
@@ -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);
}
+11
View File
@@ -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 {}
+537
View File
@@ -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;
}
+320 -75
View File
@@ -10,22 +10,36 @@ import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js'; import { DATABASE } from '../database/database.module.js';
type TemplateRow = { type TemplateRow = {
id: string; name: string; kind: 'text' | 'image'; current_version: number; id: string;
created_at: Date | string; updated_at: Date | string; revision: number; name: string;
text_content: string | null; image_asset_id: string | null; font_code: string | null; kind: 'text' | 'image';
font_size_px: number | null; opacity: string; scale_ratio: string | null; position: string; 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>; type TemplateConfig = Record<string, unknown>;
@Injectable() @Injectable()
export class WatermarksService { export class WatermarksService {
public constructor( public constructor(
@Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(DATABASE) private readonly database: Database, @Inject(DATABASE) private readonly database: Database,
) {} ) {}
public async fonts() { 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 `SELECT code, display_name FROM atlas.watermark_fonts
WHERE status = 'active' ORDER BY display_name, code`, 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 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)) 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`, 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 items = rows.rows.slice(0, query.limit).map(toTemplate);
const last = items.at(-1); const last = items.at(-1);
@@ -48,8 +66,10 @@ export class WatermarksService {
data: items, data: items,
meta: { meta: {
has_more: rows.rows.length > query.limit, has_more: rows.rows.length > query.limit,
next_cursor: rows.rows.length > query.limit && last next_cursor:
? encodeCursor(last.created_at, last.id) : null, rows.rows.length > query.limit && last
? encodeCursor(last.created_at, last.id)
: null,
}, },
}; };
}); });
@@ -62,11 +82,20 @@ export class WatermarksService {
} }
public async create( public async create(
workspaceId: string, userId: string, idempotencyKey: string, workspaceId: string,
userId: string,
idempotencyKey: string,
input: CreateWatermarkTemplateRequest, input: CreateWatermarkTemplateRequest,
) { ) {
return this.idempotency.run( 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) => { async (tx) => {
await this.validateConfig(tx, input.kind, input.config); await this.validateConfig(tx, input.kind, input.config);
const inserted = await tx.query<{ id: string }>( 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`, VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
[input.name, input.kind], [input.name, input.kind],
); );
await this.insertVersion(tx, inserted.rows[0].id, 1, input.kind, input.config, userId); await this.insertVersion(
return { status: 201, body: { data: await this.requireTemplate(tx, inserted.rows[0].id) } }; 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( public async update(
workspaceId: string, userId: string, templateId: string, idempotencyKey: string, workspaceId: string,
revision: number, input: UpdateWatermarkTemplateRequest, userId: string,
templateId: string,
idempotencyKey: string,
revision: number,
input: UpdateWatermarkTemplateRequest,
) { ) {
return this.idempotency.run( 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) => { async (tx) => {
const current = await this.lockTemplate(tx, templateId); 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; const nextKind = input.kind ?? current.kind;
if (input.kind && input.kind !== current.kind && !input.config) { if (
throw new ApiError(422, 'VALIDATION_FAILED', '切换水印类型时必须提交新类型完整配置'); 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); 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( await tx.query(
`UPDATE atlas.watermark_templates SET name = COALESCE($1, name), kind = $2, `UPDATE atlas.watermark_templates SET name = COALESCE($1, name), kind = $2,
current_version = current_version + CASE WHEN $3 THEN 1 ELSE 0 END, current_version = current_version + CASE WHEN $3 THEN 1 ELSE 0 END,
revision = revision + 1, updated_at = now() WHERE id = $4`, revision = revision + 1, updated_at = now() WHERE id = $4`,
[input.name ?? null, nextKind, renderingChanged, templateId], [
input.name ?? null,
nextKind,
renderingChanged,
templateId,
],
); );
if (renderingChanged) { 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( 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) => { async (tx) => {
await this.lockTemplate(tx, templateId); await this.lockTemplate(tx, templateId);
await tx.query(`UPDATE atlas.store_watermark_bindings SET unbound_at = now() await tx.query(
WHERE template_id = $1 AND unbound_at IS NULL`, [templateId]); `UPDATE atlas.store_watermark_bindings SET unbound_at = now()
await tx.query(`UPDATE atlas.watermark_templates SET status = 'archived', deleted_at = now(), WHERE template_id = $1 AND unbound_at IS NULL`,
revision = revision + 1, updated_at = now() WHERE id = $1`, [templateId]); [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 }; return { status: 204, body: null };
}, },
); );
} }
public async bindStore( public async bindStore(
workspaceId: string, userId: string, storeId: string, idempotencyKey: string, workspaceId: string,
userId: string,
storeId: string,
idempotencyKey: string,
templateId: string | null, templateId: string | null,
) { ) {
return this.idempotency.run( 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) => { async (tx) => {
const store = await tx.query<{ id: string }>( 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); if (templateId) await this.requireTemplate(tx, templateId);
await tx.query(`UPDATE atlas.store_watermark_bindings SET unbound_at = now() await tx.query(
WHERE store_id = $1 AND unbound_at IS NULL`, [storeId]); `UPDATE atlas.store_watermark_bindings SET unbound_at = now()
WHERE store_id = $1 AND unbound_at IS NULL`,
[storeId],
);
if (templateId) { 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) (workspace_id, store_id, template_id, bound_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`, 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; const result = templateId
return { status: 200, body: { data: { watermark_template: result ? { id: result.id, name: result.name } : null } } }; ? 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> { private async lockTemplate(
const rows = await tx.query<TemplateRow>(`${templateSelect} WHERE template.id = $1 tx: DatabaseTransaction,
AND template.status = 'active' AND template.deleted_at IS NULL FOR UPDATE`, [id]); id: string,
if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); ): 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]; return rows.rows[0];
} }
private async requireTemplate(tx: DatabaseTransaction, id: string) { private async requireTemplate(tx: DatabaseTransaction, id: string) {
const rows = await tx.query<TemplateRow>(`${templateSelect} WHERE template.id = $1 const rows = await tx.query<TemplateRow>(
AND template.status = 'active' AND template.deleted_at IS NULL`, [id]); `${templateSelect} WHERE template.id = $1
if (!rows.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '水印模板不存在'); 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]); return toTemplate(rows.rows[0]);
} }
private async validateConfig(tx: DatabaseTransaction, kind: 'text' | 'image', config: TemplateConfig) { private async validateConfig(
if (typeof config.opacity !== 'string' || typeof config.position !== 'string') { tx: DatabaseTransaction,
kind: 'text' | 'image',
config: TemplateConfig,
) {
if (
typeof config.opacity !== 'string' ||
typeof config.position !== 'string'
) {
throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '水印配置不完整');
} }
if (kind === 'text') { if (kind === 'text') {
const fontCode = config.font_code; const fontCode = config.font_code;
if (typeof fontCode !== 'string' || typeof config.text_content !== 'string' || typeof config.font_size_px !== 'number') { if (
throw new ApiError(422, 'VALIDATION_FAILED', '文字水印配置不完整'); 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]); const font = await tx.query(
if (!font.rows[0]) throw new ApiError(422, 'WATERMARK_FONT_UNAVAILABLE', '所选字体不可用'); `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; return;
} }
const assetId = config.image_asset_id; 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', '图片水印配置不完整'); throw new ApiError(422, 'VALIDATION_FAILED', '图片水印配置不完整');
} }
const asset = await tx.query(`SELECT id FROM atlas.media_assets WHERE id = $1`, [assetId]); const asset = await tx.query<{ id: string }>(
if (!asset.rows[0]) throw new ApiError(422, 'WATERMARK_IMAGE_UNAVAILABLE', '水印图片不存在或不属于当前工作空间'); `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( private async insertVersion(
tx: DatabaseTransaction, templateId: string, version: number, kind: 'text' | 'image', tx: DatabaseTransaction,
config: TemplateConfig, userId: string, 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) (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)`, 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, templateId,
config.scale_ratio ?? null, config.position, userId]); 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`; ON version.template_id = template.id AND version.version = template.current_version`;
function toTemplate(row: TemplateRow) { function toTemplate(row: TemplateRow) {
return { id: row.id, name: row.name, kind: row.kind, current_version: row.current_version, return {
config: configFromRow(row), created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; 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 { function configFromRow(row: TemplateRow): TemplateConfig {
return row.kind === 'text' 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' 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) { 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 } { function decodeCursor(cursor: string): { createdAt: string; id: string } {
try { try {
const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { createdAt?: unknown; id?: unknown }; const value = JSON.parse(
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(); 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 }; return { createdAt: value.createdAt, id: value.id };
} catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } } catch {
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
}
} }
+1 -1
View File
@@ -13,7 +13,7 @@ API 启动后会在 `/api-docs` 提供 Swagger UI,并在其 OpenAPI 文档中
| [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 | | [`conventions.md`](./conventions.md) | 部分已实现 | 鉴权、全局上下文、响应、分页、错误、并发与异步任务。 |
| [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 | | [`auth.md`](./auth.md) | 已实现 | 邮箱注册、登录、刷新会话、退出、当前用户。 |
| [`stores-and-platforms.md`](./stores-and-platforms.md) | 部分已实现 | 平台目录、页头上下文、店铺授权、门店 CRUD、默认店铺、操作查询与仓库读取;平台实际验证与同步 Worker 待实现。 | | [`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) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 | | [`pricing.md`](./pricing.md) | 已设计 | 汇率、物流价卡、即时试算、定价方案、报价快照衔接。 |
| [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 | | [`collection-and-extension.md`](./collection-and-extension.md) | 已设计 | 插件授权、采集写入、采集箱查询、素材转存与状态。 |
| [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 | | [`product-editor-and-publishing.md`](./product-editor-and-publishing.md) | 已设计 | 商品编辑、SKU、类目、属性、AI、媒体选择、定价确认与部分 SKU 首次上架。 |
+4 -4
View File
@@ -22,7 +22,7 @@ Headers:`Idempotency-Key` 必填。
} }
``` ```
首期允许 `image/png`、`image/jpeg`、`image/webp`。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和短时预签名上传地址。返回 `201 Created`: 首期允许 `image/png`、`image/jpeg`、`image/webp`,最大 5 MiB,图片宽高须为 1–8192 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`:
```json ```json
{ {
@@ -50,7 +50,7 @@ Headers:`Idempotency-Key` 必填。
{ "checksum_sha256": "hex-sha256" } { "checksum_sha256": "hex-sha256" }
``` ```
服务端检查对象存在、文件大小、MIME/魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。图片解码或安全扫描耗时时可先返回 `202` 与 `media_processing` operation。 服务端检查对象存在、文件大小、声明 MIME、文件魔数、哈希和图片尺寸;校验通过后创建/复用 `media_asset` 并返回 `201 Created`。校验失败时上传意图标记为失败,不能被水印模板引用。
```json ```json
{ {
@@ -67,7 +67,7 @@ Headers:`Idempotency-Key` 必填。
} }
``` ```
`object_key` 是服务端内部字段,不放在普通客户端响应。上传意图、完成状态和对象清理需要在实现时增加持久化记录;它不能只存在于 Redis 或预签名 token 中。 `object_key` 是服务端内部字段,不放在普通客户端响应。上传意图和完成状态均持久化在数据库,不能只存在于 Redis 或预签名 token 中;过期和失败对象由后续清理任务回收。
### 1.3 临时预览 URL ### 1.3 临时预览 URL
@@ -77,7 +77,7 @@ Headers:`Idempotency-Key` 必填。
{ "purpose": "preview" } { "purpose": "preview" }
``` ```
返回短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。 返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
## 2. 可用字体 ## 2. 可用字体
+14 -13
View File
@@ -90,12 +90,13 @@ NestJS API/Worker 通过环境变量拿到连接信息,本地 `.env` 示例:
```bash ```bash
# 对象存储(本地 MinIO / 生产 Kodo 只换这一组值) # 对象存储(本地 MinIO / 生产 Kodo 只换这一组值)
S3_ENDPOINT=http://localhost:9000 OBJECT_STORAGE_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1 # MinIO 不校验 region,任意合法值即可 OBJECT_STORAGE_BUCKET=atlas-dev-assets
S3_BUCKET=atlas-dev-assets OBJECT_STORAGE_FORCE_PATH_STYLE=true
S3_ACCESS_KEY=atlas-dev # 生产或共享环境必须提供专用最小权限凭证:
S3_SECRET_KEY=atlas-dev-secret OBJECT_STORAGE_ACCESS_KEY=atlas-dev
S3_FORCE_PATH_STYLE=true # MinIO 必须用 path-style,见第 6.3 节 OBJECT_STORAGE_SECRET_KEY=atlas-dev-secret
# 本地也可仅设置 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD,由 API 作为兼容回退读取。
``` ```
## 4. 创建 Bucket ## 4. 创建 Bucket
@@ -159,24 +160,24 @@ import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT!, // http://localhost:9000 endpoint: process.env.OBJECT_STORAGE_ENDPOINT!, // http://localhost:9000
region: process.env.S3_REGION!, // MinIO 任意值即可 region: 'us-east-1',
credentials: { credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!, accessKeyId: process.env.OBJECT_STORAGE_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!, secretAccessKey: process.env.OBJECT_STORAGE_SECRET_KEY!,
}, },
forcePathStyle: true, // MinIO 必须;Kodo 走虚拟主机式,配置驱动即可 forcePathStyle: process.env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
}); });
// 预签名上传 URL:前端拿到后直传,浏览器不保存永久密钥 // 预签名上传 URL:前端拿到后直传,浏览器不保存永久密钥
export async function presignUpload(objectKey: string, contentType: string) { export async function presignUpload(objectKey: string, contentType: string) {
const cmd = new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey, ContentType: contentType }); const cmd = new PutObjectCommand({ Bucket: process.env.OBJECT_STORAGE_BUCKET, Key: objectKey, ContentType: contentType });
return getSignedUrl(s3, cmd, { expiresIn: 600 }); // 10 分钟 return getSignedUrl(s3, cmd, { expiresIn: 600 }); // 10 分钟
} }
// 预签名下载 URL:预览/下载私有文件 // 预签名下载 URL:预览/下载私有文件
export async function presignDownload(objectKey: string) { export async function presignDownload(objectKey: string) {
const cmd = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: objectKey }); const cmd = new GetObjectCommand({ Bucket: process.env.OBJECT_STORAGE_BUCKET, Key: objectKey });
return getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 分钟 return getSignedUrl(s3, cmd, { expiresIn: 900 }); // 15 分钟
} }
``` ```
+7 -1
View File
@@ -64,7 +64,13 @@
- 已实现字体目录、按工作空间隔离的水印模板列表/详情/创建/编辑/软删除,以及店铺推荐水印绑定与解绑。文字/图片模板均创建不可变渲染版本;仅改名不新增版本,渲染参数或类型变化会新增版本。 - 已实现字体目录、按工作空间隔离的水印模板列表/详情/创建/编辑/软删除,以及店铺推荐水印绑定与解绑。文字/图片模板均创建不可变渲染版本;仅改名不新增版本,渲染参数或类型变化会新增版本。
- 删除模板会解除所有当前店铺绑定,但不删除历史版本或图片资产。模板与店铺写操作均要求 `Idempotency-Key`;编辑要求 `If-Match`。 - 删除模板会解除所有当前店铺绑定,但不删除历史版本或图片资产。模板与店铺写操作均要求 `Idempotency-Key`;编辑要求 `If-Match`。
- 图片模板可校验已有 `media_asset` 的归属;素材直传、图片校验和短时预览 URL 尚未实现,不能在前端自行拼接对象存储地址。 - 图片模板只可引用当前工作空间内、已完成安全校验的 `media_asset`;素材直传、图片校验和短时预览 URL 的实现见 1.6,前端仍不能自行拼接对象存储地址。
### 1.6 开发进度(2026-09-04):水印图片素材直传
- 已实现仅供 `watermark_image` 使用的两阶段素材接口:创建 10 分钟有效的 MinIO/S3 预签名 PUT 上传意图、完成上传后验证对象存在、大小、SHA-256、PNG/JPEG/WebP 魔数与 1–8192 px 尺寸,并创建或复用工作空间内的 `media_asset`。
- 已实现素材短时预览 URL(10 分钟);普通 API 返回永不包含 bucket、object key 或长期 URL。图片模板只接受当前工作空间内、类型为 PNG/JPEG/WebP 的已验证素材。
- 本地环境默认使用 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 连接 `http://localhost:9000`;生产必须显式配置最小权限的 `OBJECT_STORAGE_ACCESS_KEY` / `OBJECT_STORAGE_SECRET_KEY`。
## 2. 已确认产品决策 ## 2. 已确认产品决策
+1
View File
@@ -2,3 +2,4 @@ export * from './collection';
export * from './auth'; export * from './auth';
export * from './stores'; export * from './stores';
export * from './watermarks'; export * from './watermarks';
export * from './media';
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
const checksum = z
.string()
.regex(/^[a-f0-9]{64}$/, '必须是 SHA-256 十六进制摘要');
export const createMediaUploadIntentRequestSchema = z
.object({
purpose: z.literal('watermark_image'),
filename: z.string().trim().min(1).max(255),
content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']),
byte_size: z
.number()
.int()
.min(1)
.max(5 * 1024 * 1024),
checksum_sha256: checksum,
})
.strict();
export const completeMediaUploadIntentRequestSchema = z
.object({
checksum_sha256: checksum,
})
.strict();
export const createMediaAccessUrlRequestSchema = z
.object({
purpose: z.literal('preview'),
})
.strict();
export type CreateMediaUploadIntentRequest = z.infer<
typeof createMediaUploadIntentRequestSchema
>;
export type CompleteMediaUploadIntentRequest = z.infer<
typeof completeMediaUploadIntentRequestSchema
>;
+1610 -2219
View File
File diff suppressed because it is too large Load Diff