feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
#!/usr/bin/env npx tsx
/**
* 本地存储 → MinIO 迁移脚本
* 使用 @aws-sdk/client-s3项目已有依赖
*
* 用法: npx tsx scripts/migrate-local-to-minio.ts
*/
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
import * as fs from 'fs/promises'
import * as path from 'path'
import { createReadStream } from 'fs'
// ==================== 配置 ====================
const LOCAL_DIR = process.env.LOCAL_UPLOAD_DIR || './data/uploads'
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://127.0.0.1:19000'
const MINIO_BUCKET = process.env.MINIO_BUCKET || 'waoowaoo'
const MINIO_REGION = process.env.MINIO_REGION || 'us-east-1'
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || 'minioadmin'
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || 'minioadmin'
const CONCURRENCY = parseInt(process.env.MIGRATE_CONCURRENCY || '10')
const DRY_RUN = process.env.MIGRATE_DRY_RUN === 'true'
// ==================== S3 客户端 ====================
const s3 = new S3Client({
endpoint: MINIO_ENDPOINT,
region: MINIO_REGION,
forcePathStyle: true,
credentials: {
accessKeyId: MINIO_ACCESS_KEY,
secretAccessKey: MINIO_SECRET_KEY,
},
})
// ==================== 工具函数 ====================
function guessContentType(filename: string): string {
const ext = path.extname(filename).toLowerCase()
const types: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.json': 'application/json',
'.txt': 'text/plain',
}
return types[ext] || 'application/octet-stream'
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// ==================== 扫描本地文件 ====================
async function scanLocalFiles(dir: string, basePath = ''): Promise<Array<{ localPath: string; key: string; size: number }>> {
const files: Array<{ localPath: string; key: string; size: number }> = []
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
const relativePath = path.join(basePath, entry.name)
if (entry.isDirectory()) {
files.push(...await scanLocalFiles(fullPath, relativePath))
} else {
// 跳过隐藏文件
if (entry.name.startsWith('.')) continue
const stats = await fs.stat(fullPath)
files.push({
localPath: fullPath,
key: relativePath.replace(/\\/g, '/'),
size: stats.size,
})
}
}
} catch (err: unknown) {
console.error(` ⚠️ 无法读取目录: ${dir}`, (err as Error).message)
}
return files
}
// ==================== 检查文件是否已存在 ====================
async function objectExists(key: string): Promise<boolean> {
try {
await s3.send(new HeadObjectCommand({ Bucket: MINIO_BUCKET, Key: key }))
return true
} catch {
return false
}
}
// ==================== 上传文件 ====================
async function uploadFile(file: { localPath: string; key: string; size: number }): Promise<'success' | 'skipped' | 'error'> {
// 检查是否已存在
if (await objectExists(file.key)) {
return 'skipped'
}
if (DRY_RUN) {
console.log(` [DRY RUN] 将上传: ${file.key} (${formatBytes(file.size)})`)
return 'skipped'
}
try {
const body = await fs.readFile(file.localPath)
await s3.send(new PutObjectCommand({
Bucket: MINIO_BUCKET,
Key: file.key,
Body: body,
ContentType: guessContentType(file.key),
}))
return 'success'
} catch (err: unknown) {
console.error(` ✗ 上传失败: ${file.key}`, (err as Error).message)
return 'error'
}
}
// ==================== 并行控制 ====================
async function runBatched<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) {
for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency)
await Promise.all(batch.map(fn))
}
}
// ==================== 主流程 ====================
async function main() {
console.log()
console.log('╔══════════════════════════════════════════════════════╗')
console.log('║ Local Storage → MinIO Migration Tool ║')
console.log('╚══════════════════════════════════════════════════════╝')
console.log()
console.log(` 📂 源目录: ${path.resolve(LOCAL_DIR)}`)
console.log(` 🪣 目标桶: ${MINIO_ENDPOINT}/${MINIO_BUCKET}`)
console.log(` ⚡ 并发数: ${CONCURRENCY}`)
console.log(` 🔍 干运行: ${DRY_RUN}`)
console.log()
// 1. 扫描文件
console.log('📦 扫描本地文件...')
const files = await scanLocalFiles(LOCAL_DIR)
if (files.length === 0) {
console.log(' 没有需要迁移的文件')
return
}
const totalSize = files.reduce((sum, f) => sum + f.size, 0)
console.log(` 找到 ${files.length} 个文件, 总大小: ${formatBytes(totalSize)}`)
console.log()
// 2. 开始上传
console.log('🚀 开始迁移...')
const startTime = Date.now()
let success = 0
let skipped = 0
let failed = 0
let processed = 0
await runBatched(files, CONCURRENCY, async (file) => {
const result = await uploadFile(file)
processed++
if (result === 'success') {
success++
if (success % 50 === 0 || success <= 5) {
console.log(` ✓ [${processed}/${files.length}] ${file.key} (${formatBytes(file.size)})`)
}
} else if (result === 'skipped') {
skipped++
} else {
failed++
}
if (processed % 100 === 0) {
const pct = ((processed / files.length) * 100).toFixed(1)
console.log(` 📊 进度: ${pct}% (${processed}/${files.length}) | ✓${success}${skipped}${failed}`)
}
})
// 3. 结果
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
console.log()
console.log('╔══════════════════════════════════════════════════════╗')
console.log('║ 迁移完成 ║')
console.log('╠══════════════════════════════════════════════════════╣')
console.log(`║ 总文件: ${String(files.length).padEnd(40)}`)
console.log(`║ 成功: ${String(success).padEnd(40)}`)
console.log(`║ 跳过: ${String(skipped).padEnd(40)}`)
console.log(`║ 失败: ${String(failed).padEnd(40)}`)
console.log(`║ 耗时: ${String(duration + 's').padEnd(40)}`)
console.log(`║ 大小: ${formatBytes(totalSize).padEnd(40)}`)
console.log('╚══════════════════════════════════════════════════════╝')
if (failed > 0) {
console.log()
console.log('⚠️ 有文件上传失败,请重新运行脚本(已上传的会自动跳过)')
process.exit(1)
}
}
main().catch(err => {
console.error('迁移失败:', err)
process.exit(1)
})