Update seller store experience

This commit is contained in:
R524809
2026-09-08 16:19:38 +08:00
parent ceb644a3f9
commit 319887726e
25 changed files with 31417 additions and 127 deletions
+22 -11
View File
@@ -5,11 +5,12 @@ import { CreateBucketCommand, HeadBucketCommand, PutObjectCommand, type S3Client
import type { Database } from '@atlas/database';
import type { OutboxJob } from './outbox-dispatcher.js';
const MAX_BYTES = 20 * 1024 * 1024;
type Transfer = { id: string; source_url: string; source_variant_id: string | null; source_snapshot_revision: string | number };
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const MAX_VIDEO_BYTES = 200 * 1024 * 1024;
type Transfer = { id: string; source_url: string; source_variant_id: string | null; source_snapshot_revision: string | number; media_type: 'image' | 'video' };
/** Downloads source images only after a public-address check. A failed image is
* terminal for this transfer, not for the product collection transaction. */
/** Downloads selected source media only after a public-address check. A failed
* transfer is terminal for that candidate, not for the product collection transaction. */
export async function materializeCollectionAssets(database: Database, storage: S3Client, bucket: string, job: OutboxJob) {
await ensureBucket(storage, bucket);
const snapshotRevision = Number(job.payload.source_snapshot_revision ?? 1);
@@ -17,7 +18,7 @@ export async function materializeCollectionAssets(database: Database, storage: S
const transfers = await database.workspaceTransaction(job.workspaceId, async (tx) => {
const rows = await tx.query<Transfer>(`UPDATE atlas.collection_asset_transfers SET status = 'running', updated_at = now()
WHERE id IN (SELECT id FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 AND status = 'queued' ORDER BY created_at FOR UPDATE SKIP LOCKED)
RETURNING id, source_url, source_variant_id, source_snapshot_revision`, [String(job.payload.collection_item_id), snapshotRevision]);
RETURNING id, source_url, source_variant_id, source_snapshot_revision, media_type::text AS media_type`, [String(job.payload.collection_item_id), snapshotRevision]);
return rows.rows;
});
for (const transfer of transfers) await materializeOne(database, storage, bucket, job.workspaceId, transfer);
@@ -29,7 +30,7 @@ export async function materializeCollectionAssets(database: Database, storage: S
async function materializeOne(database: Database, storage: S3Client, bucket: string, workspaceId: string, transfer: Transfer) {
const snapshotRevision = Number(transfer.source_snapshot_revision);
try {
const downloaded = await downloadImage(transfer.source_url);
const downloaded = await downloadMedia(transfer.source_url, transfer.media_type);
const current = await database.workspaceTransaction(workspaceId, async (tx) => (await tx.query<{ id: string }>(`SELECT t.id FROM atlas.collection_asset_transfers t JOIN atlas.collection_items c ON c.id = t.collection_item_id
WHERE t.id = $1 AND t.status = 'running' AND t.source_snapshot_revision = $2 AND c.source_snapshot_revision = $2`, [transfer.id, snapshotRevision])).rows[0]);
if (!current) return;
@@ -52,19 +53,24 @@ async function materializeOne(database: Database, storage: S3Client, bucket: str
}
}
async function downloadImage(source: string) {
async function downloadMedia(source: string, mediaType: Transfer['media_type']) {
let url = new URL(source);
if (mediaType === 'video' && /\.m3u8(?:$|[?#])/i.test(url.pathname))
throw new Error('暂不支持 HLS 视频转存');
for (let redirect = 0; redirect < 4; redirect += 1) {
await assertPublicUrl(url);
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20_000), headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8', 'User-Agent': 'Atlas-Collector/0.1' } });
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(60_000), headers: { Accept: mediaType === 'video' ? 'video/mp4,video/webm,*/*;q=0.8' : 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8', 'User-Agent': 'Atlas-Collector/0.1' } });
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location'); if (!location) throw new Error('来源素材重定向缺少地址'); url = new URL(location, url); continue;
}
if (!response.ok || !response.body) throw new Error(`来源素材请求失败(HTTP ${response.status})`);
const chunks: Buffer[] = []; let total = 0;
for await (const chunk of response.body as AsyncIterable<Uint8Array>) { total += chunk.byteLength; if (total > MAX_BYTES) throw new Error('来源素材超过 20MB'); chunks.push(Buffer.from(chunk)); }
const bytes = Buffer.concat(chunks, total); const image = detectImage(bytes); if (!image) throw new Error('来源素材不是支持的 PNG、JPEG 或 WebP 图片');
return { bytes, ...image };
const limit = mediaType === 'video' ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
for await (const chunk of response.body as AsyncIterable<Uint8Array>) { total += chunk.byteLength; if (total > limit) throw new Error(mediaType === 'video' ? '来源视频超过 200MB' : '来源图片超过 20MB'); chunks.push(Buffer.from(chunk)); }
const bytes = Buffer.concat(chunks, total);
const media = mediaType === 'video' ? detectVideo(bytes) : detectImage(bytes);
if (!media) throw new Error(mediaType === 'video' ? '来源素材不是支持的 MP4 或 WebM 视频' : '来源素材不是支持的 PNG、JPEG 或 WebP 图片');
return { bytes, ...media };
}
throw new Error('来源素材重定向次数过多');
}
@@ -84,4 +90,9 @@ function detectImage(bytes: Buffer): { contentType: 'image/png' | 'image/jpeg' |
if (bytes.length > 9 && 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], 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 += length + 2; }
return null;
}
function detectVideo(bytes: Buffer): { contentType: 'video/mp4' | 'video/webm'; width: null; height: null } | null {
if (bytes.length >= 12 && bytes.subarray(4, 8).toString('ascii') === 'ftyp') return { contentType: 'video/mp4', width: null, height: null };
if (bytes.length >= 4 && bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) return { contentType: 'video/webm', width: null, height: null };
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; }