feat(edit): AI套图生成、图片切七牛

This commit is contained in:
2026-10-04 20:47:47 +08:00
parent 696112e74d
commit e20cd7547f
60 changed files with 6241 additions and 312 deletions
+83 -30
View File
@@ -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 () => {