feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
GetObjectCommand,
|
||||
@@ -48,6 +48,7 @@ type AssetRow = {
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly s3: S3Client;
|
||||
private readonly legacyMinioS3: S3Client | null;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
public constructor(
|
||||
@@ -58,13 +59,29 @@ export class MediaService {
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: config.OBJECT_STORAGE_ENDPOINT,
|
||||
region: 'us-east-1',
|
||||
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(
|
||||
@@ -273,11 +290,12 @@ export class MediaService {
|
||||
) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const asset = await tx.query<{
|
||||
storage_provider: string;
|
||||
bucket: string;
|
||||
object_key: string;
|
||||
content_type: string;
|
||||
}>(
|
||||
`SELECT bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
`SELECT storage_provider, bucket, object_key, content_type FROM atlas.media_assets WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
if (!asset.rows[0])
|
||||
@@ -288,16 +306,10 @@ export class MediaService {
|
||||
'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 getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({
|
||||
Bucket: asset.rows[0].bucket,
|
||||
Key: asset.rows[0].object_key,
|
||||
...(purpose === 'download' ? {
|
||||
ResponseContentDisposition: `attachment; filename="atlas-image-${assetId}.${extension}"`,
|
||||
} : {}),
|
||||
}),
|
||||
{ expiresIn: 600 },
|
||||
const url = await this.signedReadUrl(
|
||||
asset.rows[0],
|
||||
expiresAt,
|
||||
purpose === 'download' ? `atlas-image-${assetId}.${extension}` : undefined,
|
||||
);
|
||||
return { url, expires_at: expiresAt.toISOString() };
|
||||
});
|
||||
@@ -306,8 +318,8 @@ export class MediaService {
|
||||
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
|
||||
public async productUploads(workspaceId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const uploads = await tx.query<AssetRow & { bucket: string; object_key: string }>(
|
||||
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
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
|
||||
@@ -326,11 +338,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString()
|
||||
: asset.created_at,
|
||||
preview_url: await getSignedUrl(
|
||||
this.s3,
|
||||
new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }),
|
||||
{ expiresIn: 600 },
|
||||
),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -387,15 +395,12 @@ export class MediaService {
|
||||
[input.url, assetId, userId],
|
||||
);
|
||||
const asset = await this.requireAsset(tx, assetId);
|
||||
const stored = await tx.query<{ bucket: string; object_key: string }>(
|
||||
'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1',
|
||||
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 getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: stored.rows[0].bucket,
|
||||
Key: stored.rows[0].object_key,
|
||||
}), { expiresIn: 600 });
|
||||
const previewUrl = await this.signedReadUrl(stored.rows[0], expiresAt);
|
||||
return {
|
||||
status: 201,
|
||||
body: { data: { ...asset, source_url: input.url,
|
||||
@@ -408,11 +413,12 @@ export class MediaService {
|
||||
public async productImports(workspaceId: 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.content_type, a.byte_size::text, a.width_px, a.height_px,
|
||||
`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
|
||||
@@ -433,9 +439,7 @@ export class MediaService {
|
||||
created_at: asset.created_at instanceof Date
|
||||
? asset.created_at.toISOString() : asset.created_at,
|
||||
source_url: asset.source_url,
|
||||
preview_url: await getSignedUrl(this.s3, new GetObjectCommand({
|
||||
Bucket: asset.bucket, Key: asset.object_key,
|
||||
}), { expiresIn: 600 }),
|
||||
preview_url: await this.signedReadUrl(asset, new Date(expiresAt)),
|
||||
preview_url_expires_at: expiresAt,
|
||||
})));
|
||||
});
|
||||
@@ -560,6 +564,55 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 七牛图片从绑定域名签发短时下载凭证;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 () => {
|
||||
|
||||
Reference in New Issue
Block a user