feat(api-water): 支持上传水印图片
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
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
@@ -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 首次上架。 |
|
||||||
|
|||||||
@@ -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. 可用字体
|
||||||
|
|
||||||
|
|||||||
@@ -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 分钟
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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. 已确认产品决策
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
>;
|
||||||
Generated
+5386
-5995
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user