688 lines
28 KiB
TypeScript
688 lines
28 KiB
TypeScript
import { createHash, createHmac, 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,
|
|
CreateMediaAccessUrlRequest,
|
|
CreateMediaUploadIntentRequest,
|
|
ImportProductImageRequest,
|
|
} 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';
|
|
import { detectImage } from './media-image-validation.js';
|
|
import { downloadProductImage } from './product-image-download.js';
|
|
|
|
type IntentRow = {
|
|
id: string;
|
|
purpose: 'watermark_image' | 'product_image';
|
|
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 readonly legacyMinioS3: S3Client | null;
|
|
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: config.OBJECT_STORAGE_REGION,
|
|
forcePathStyle: config.objectStorageForcePathStyle,
|
|
credentials: {
|
|
accessKeyId: config.objectStorageAccessKey,
|
|
secretAccessKey: config.objectStorageSecretKey,
|
|
},
|
|
});
|
|
// 开发环境保留旧 MinIO 素材预览,历史素材正式发布前仍需迁移到七牛。
|
|
this.legacyMinioS3 =
|
|
config.NODE_ENV !== 'production' &&
|
|
config.OBJECT_STORAGE_PROVIDER === 'qiniu_kodo' &&
|
|
config.MINIO_ROOT_USER &&
|
|
config.MINIO_ROOT_PASSWORD
|
|
? new S3Client({
|
|
endpoint: config.LEGACY_MINIO_ENDPOINT,
|
|
region: 'us-east-1',
|
|
forcePathStyle: true,
|
|
credentials: {
|
|
accessKeyId: config.MINIO_ROOT_USER,
|
|
secretAccessKey: config.MINIO_ROOT_PASSWORD,
|
|
},
|
|
})
|
|
: null;
|
|
}
|
|
|
|
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) => {
|
|
if (input.purpose === 'product_image') {
|
|
if (!input.product_id) throw new ApiError(422, 'VALIDATION_FAILED', '商品图片必须绑定商品');
|
|
await this.requireProduct(tx, input.product_id);
|
|
}
|
|
await this.ensureBucket();
|
|
const id = randomUUID();
|
|
const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : '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, product_id)
|
|
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
|
|
$3, $4, $5, $6, $7, $8, $9, $11)`,
|
|
[
|
|
id,
|
|
input.purpose,
|
|
this.config.OBJECT_STORAGE_BUCKET,
|
|
objectKey,
|
|
input.content_type,
|
|
input.byte_size,
|
|
input.checksum_sha256,
|
|
expiresAt,
|
|
userId,
|
|
this.config.OBJECT_STORAGE_PROVIDER,
|
|
input.product_id ?? null,
|
|
],
|
|
);
|
|
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, $9, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[
|
|
assetId,
|
|
intent.bucket,
|
|
intent.object_key,
|
|
inspected.contentType,
|
|
inspected.byteSize,
|
|
inspected.checksum,
|
|
inspected.width,
|
|
inspected.height,
|
|
intent.storage_provider,
|
|
],
|
|
);
|
|
}
|
|
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,
|
|
purpose: CreateMediaAccessUrlRequest['purpose'] = 'preview',
|
|
) {
|
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const asset = await tx.query<{
|
|
storage_provider: string;
|
|
bucket: string;
|
|
object_key: string;
|
|
content_type: string;
|
|
}>(
|
|
`SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1 AND deleted_at IS NULL`,
|
|
[assetId],
|
|
);
|
|
if (!asset.rows[0])
|
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '素材不存在');
|
|
const extension = ({
|
|
'image/jpeg': 'jpg',
|
|
'image/png': 'png',
|
|
'image/webp': 'webp',
|
|
} as Record<string, string>)[asset.rows[0].content_type] ?? 'bin';
|
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
|
const url = await this.signedReadUrl(
|
|
asset.rows[0],
|
|
expiresAt,
|
|
purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
|
|
);
|
|
return { url, expires_at: expiresAt.toISOString() };
|
|
});
|
|
}
|
|
|
|
/** 返回当前商品最近上传的素材,预览 URL 仅在本次响应中签发。 */
|
|
public async productUploads(workspaceId: string, productId: string) {
|
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const uploads = await tx.query<AssetRow & { storage_provider: string; bucket: string; object_key: string }>(
|
|
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
|
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
|
|
FROM atlas.media_assets a
|
|
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
|
|
WHERE i.purpose = 'product_image' AND i.status = 'ready' AND i.product_id = $1 AND a.deleted_at IS NULL
|
|
GROUP BY a.id
|
|
ORDER BY uploaded_at DESC, a.id DESC
|
|
LIMIT 100`,
|
|
[productId],
|
|
);
|
|
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
|
return Promise.all(uploads.rows.map(async (asset) => ({
|
|
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,
|
|
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
|
preview_url_expires_at: expiresAt,
|
|
})));
|
|
});
|
|
}
|
|
|
|
/** 网络图片先做公网下载与内容校验,再登记为工作空间正式素材。 */
|
|
public async importProductImage(
|
|
workspaceId: string,
|
|
userId: string,
|
|
key: string,
|
|
input: ImportProductImageRequest,
|
|
) {
|
|
return this.idempotency.run(
|
|
{ workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input },
|
|
async (tx) => {
|
|
await this.requireProduct(tx, input.product_id);
|
|
const image = await downloadProductImage(input.url);
|
|
const checksum = createHash('sha256').update(image.bytes).digest('hex');
|
|
const existing = await tx.query<{ id: string }>(
|
|
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
|
[checksum],
|
|
);
|
|
let assetId = existing.rows[0]?.id ?? randomUUID();
|
|
if (!existing.rows[0]) {
|
|
await this.ensureBucket();
|
|
const objectKey = `product-imports/${workspaceId}/${checksum}/${randomUUID()}`;
|
|
await this.s3.send(new PutObjectCommand({
|
|
Bucket: this.config.OBJECT_STORAGE_BUCKET,
|
|
Key: objectKey,
|
|
Body: image.bytes,
|
|
ContentType: image.contentType,
|
|
}));
|
|
const inserted = await tx.query<{ id: string }>(
|
|
`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, $9,
|
|
$2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
|
|
RETURNING id`,
|
|
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
|
|
image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER],
|
|
);
|
|
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
|
|
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
|
[checksum],
|
|
)).rows[0].id;
|
|
}
|
|
await tx.query(
|
|
`INSERT INTO atlas.product_image_imports
|
|
(workspace_id, source_url, asset_id, created_by_user_id, product_id)
|
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4)
|
|
ON CONFLICT (workspace_id, product_id, source_url)
|
|
DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`,
|
|
[input.url, assetId, userId, input.product_id],
|
|
);
|
|
const asset = await this.requireAsset(tx, assetId);
|
|
const stored = await tx.query<{ storage_provider: string; bucket: string; object_key: string }>(
|
|
'SELECT storage_provider, bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
|
[assetId],
|
|
);
|
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
|
const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt);
|
|
return {
|
|
status: 201,
|
|
body: { data: { ...asset, source_url: input.url,
|
|
preview_url: previewUrl, preview_url_expires_at: expiresAt.toISOString() } },
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
public async productImports(workspaceId: string, productId: string) {
|
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const imports = await tx.query<AssetRow & {
|
|
storage_provider: string;
|
|
bucket: string;
|
|
object_key: string;
|
|
source_url: string;
|
|
}>(
|
|
`SELECT a.id, a.storage_provider, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
|
a.created_at, a.bucket, a.object_key, i.source_url
|
|
FROM (
|
|
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
|
|
FROM atlas.product_image_imports
|
|
WHERE product_id = $1
|
|
ORDER BY asset_id, updated_at DESC, id DESC
|
|
) i
|
|
JOIN atlas.media_assets a ON a.id = i.asset_id AND a.deleted_at IS NULL
|
|
ORDER BY i.updated_at DESC, i.id DESC
|
|
LIMIT 100`,
|
|
[productId],
|
|
);
|
|
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
|
|
return Promise.all(imports.rows.map(async (asset) => ({
|
|
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,
|
|
source_url: asset.source_url,
|
|
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
|
preview_url_expires_at: expiresAt,
|
|
})));
|
|
});
|
|
}
|
|
|
|
private async requireProduct(tx: DatabaseTransaction, productId: string) {
|
|
const result = await tx.query('SELECT id FROM atlas.products WHERE id = $1', [productId]);
|
|
if (!result.rows.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '商品不存在');
|
|
}
|
|
|
|
private async lockIntent(
|
|
tx: DatabaseTransaction,
|
|
intentId: string,
|
|
): Promise<IntentRow> {
|
|
const rows = await tx.query<IntentRow>(
|
|
`SELECT id, purpose, 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 AND deleted_at IS NULL`,
|
|
[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',
|
|
'尚未找到已上传文件',
|
|
);
|
|
}
|
|
const byteLimit = intent.purpose === 'product_image' ? 20 * 1024 * 1024 : 5 * 1024 * 1024;
|
|
if (!head.ContentLength || head.ContentLength > byteLimit) {
|
|
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 > byteLimit) {
|
|
throw new ApiError(
|
|
422,
|
|
'UPLOAD_VALIDATION_FAILED',
|
|
'上传文件大小不符合要求',
|
|
);
|
|
}
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
const bytes = Buffer.concat(chunks, byteSize);
|
|
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
|
|
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'),
|
|
};
|
|
}
|
|
|
|
/** 七牛图片从绑定域名签发短时下载凭证;MinIO 素材仍使用 S3 预签名。 */
|
|
private async signedReadUrl(
|
|
asset: { storage_provider: string; bucket: string; object_key: string },
|
|
expiresAt: Date,
|
|
downloadName?: string,
|
|
): Promise<string> {
|
|
if (
|
|
asset.storage_provider === 'qiniu_kodo' &&
|
|
asset.bucket === this.config.OBJECT_STORAGE_BUCKET &&
|
|
this.config.qiniuDownloadOrigin
|
|
) {
|
|
const origin = new URL(this.config.qiniuDownloadOrigin).origin;
|
|
const path = asset.object_key.split('/').map(encodeURIComponent).join('/');
|
|
const deadline = Math.floor(expiresAt.getTime() / 1000);
|
|
const attachment = downloadName
|
|
? `attname=${encodeURIComponent(downloadName)}&`
|
|
: '';
|
|
const unsignedUrl = `${origin}/${path}?${attachment}e=${deadline}`;
|
|
const signature = createHmac('sha1', this.config.qiniuDownloadSecretKey)
|
|
.update(unsignedUrl)
|
|
.digest('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_');
|
|
return `${unsignedUrl}&token=${this.config.qiniuDownloadAccessKey}:${signature}`;
|
|
}
|
|
return getSignedUrl(
|
|
this.storageForPreview(asset),
|
|
new GetObjectCommand({
|
|
Bucket: asset.bucket,
|
|
Key: asset.object_key,
|
|
...(downloadName ? {
|
|
ResponseContentDisposition: `attachment; filename="${downloadName}"`,
|
|
} : {}),
|
|
}),
|
|
{ expiresIn: Math.max(1, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) },
|
|
);
|
|
}
|
|
|
|
private storageForPreview(asset: { storage_provider: string; bucket: string }): S3Client {
|
|
if (
|
|
asset.storage_provider === 'local_minio' &&
|
|
asset.bucket === 'atlas-assets' &&
|
|
this.legacyMinioS3
|
|
) {
|
|
return this.legacyMinioS3;
|
|
}
|
|
return this.s3;
|
|
}
|
|
|
|
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;
|
|
}
|