177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
import crypto from 'node:crypto'
|
||
import process from 'node:process'
|
||
|
||
import {
|
||
createConfiguredObjectStorage,
|
||
type ObjectStorage,
|
||
} from './services/file-storage/object-storage.js'
|
||
|
||
const command = process.argv[2] || ''
|
||
const concurrency = readConcurrency()
|
||
if (!['copy', 'verify'].includes(command)) {
|
||
console.error('用法:node dist/storage-migrate.js copy|verify')
|
||
process.exit(2)
|
||
}
|
||
|
||
const source = createConfiguredObjectStorage(readStorageConfig('STORAGE'))
|
||
const destination = createConfiguredObjectStorage(readStorageConfig('STORAGE_OSS'))
|
||
|
||
if (command === 'copy') {
|
||
await copyObjects(source, destination)
|
||
} else {
|
||
await verifyObjects(source, destination)
|
||
}
|
||
|
||
function readStorageConfig(prefix: 'STORAGE' | 'STORAGE_OSS') {
|
||
return {
|
||
endpoint: required(`${prefix}_ENDPOINT`),
|
||
bucket: required(`${prefix}_BUCKET`),
|
||
accessKeyId: required(`${prefix}_ACCESS_KEY_ID`),
|
||
secretAccessKey: required(`${prefix}_SECRET_ACCESS_KEY`),
|
||
region: process.env[`${prefix}_REGION`] || 'us-east-1',
|
||
}
|
||
}
|
||
|
||
async function copyObjects(sourceStorage: ObjectStorage, destinationStorage: ObjectStorage) {
|
||
const objects = await sourceStorage.listObjects()
|
||
console.info(`源存储对象数:${objects.length}`)
|
||
let copied = 0
|
||
let skipped = 0
|
||
let completed = 0
|
||
await runConcurrent(objects, concurrency, async (object) => {
|
||
const existing = await readStat(destinationStorage, object.key)
|
||
const sourceEtag = normalizeEtag(object.etag)
|
||
const destinationEtag = normalizeEtag(existing?.etag)
|
||
if (
|
||
existing &&
|
||
Number(existing.size || 0) === object.size &&
|
||
sourceEtag &&
|
||
destinationEtag &&
|
||
destinationEtag === sourceEtag
|
||
) {
|
||
skipped += 1
|
||
} else {
|
||
const stored = await sourceStorage.getObject(object.key)
|
||
const content = await readBuffer(stored.reader)
|
||
await destinationStorage.putObject({
|
||
key: object.key,
|
||
content,
|
||
contentType: stored.contentType,
|
||
})
|
||
copied += 1
|
||
}
|
||
completed += 1
|
||
logProgress('复制', completed, objects.length)
|
||
})
|
||
console.info(`复制完成:新增/覆盖 ${copied},已存在且大小、ETag 一致 ${skipped}`)
|
||
}
|
||
|
||
async function verifyObjects(sourceStorage: ObjectStorage, destinationStorage: ObjectStorage) {
|
||
const sourceObjects = await sourceStorage.listObjects()
|
||
const destinationObjects = await destinationStorage.listObjects()
|
||
const destinationByKey = new Map(destinationObjects.map((item) => [item.key, item]))
|
||
const missing = sourceObjects.filter((item) => !destinationByKey.has(item.key))
|
||
const mismatches: string[] = []
|
||
let completed = 0
|
||
await runConcurrent(sourceObjects, concurrency, async (object) => {
|
||
const destination = destinationByKey.get(object.key)
|
||
if (!destination) {
|
||
completed += 1
|
||
logProgress('校验', completed, sourceObjects.length)
|
||
return
|
||
}
|
||
if (Number(destination.size || 0) !== object.size) {
|
||
mismatches.push(object.key)
|
||
completed += 1
|
||
logProgress('校验', completed, sourceObjects.length)
|
||
return
|
||
}
|
||
const sourceStored = await sourceStorage.getObject(object.key)
|
||
const destinationStored = await destinationStorage.getObject(object.key)
|
||
const [sourceHash, destinationHash] = await Promise.all([
|
||
hashStream(sourceStored.reader),
|
||
hashStream(destinationStored.reader),
|
||
])
|
||
if (
|
||
sourceHash !== destinationHash ||
|
||
sourceStored.contentType !== destinationStored.contentType ||
|
||
sourceStored.size !== destinationStored.size
|
||
) {
|
||
mismatches.push(object.key)
|
||
}
|
||
completed += 1
|
||
logProgress('校验', completed, sourceObjects.length)
|
||
})
|
||
if (missing.length || mismatches.length) {
|
||
console.error(`校验失败:缺失 ${missing.length},内容不一致 ${mismatches.length}`)
|
||
if (missing.length) console.error(`缺失对象:${missing.slice(0, 20).join(', ')}`)
|
||
if (mismatches.length) console.error(`不一致对象:${mismatches.slice(0, 20).join(', ')}`)
|
||
process.exit(1)
|
||
}
|
||
console.info(`校验通过:${sourceObjects.length} 个源对象全部存在且内容一致`)
|
||
}
|
||
|
||
async function runConcurrent<T>(
|
||
items: T[],
|
||
limit: number,
|
||
worker: (item: T) => Promise<void>,
|
||
): Promise<void> {
|
||
let nextIndex = 0
|
||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||
while (true) {
|
||
const index = nextIndex++
|
||
const item = items[index]
|
||
if (item === undefined) return
|
||
await worker(item)
|
||
}
|
||
})
|
||
await Promise.all(workers)
|
||
}
|
||
|
||
function normalizeEtag(value: unknown): string {
|
||
return String(value || '')
|
||
.trim()
|
||
.replace(/^"|"$/g, '')
|
||
.toLowerCase()
|
||
}
|
||
|
||
function logProgress(label: string, completed: number, total: number): void {
|
||
if (completed === total || completed % 100 === 0) {
|
||
console.info(`${label}进度:${completed}/${total}(并发 ${concurrency})`)
|
||
}
|
||
}
|
||
|
||
async function readStat(storage: ObjectStorage, key: string) {
|
||
try {
|
||
return await storage.statObject(key)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function readBuffer(reader: NodeJS.ReadableStream): Promise<Buffer> {
|
||
const chunks: Buffer[] = []
|
||
for await (const chunk of reader) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||
return Buffer.concat(chunks)
|
||
}
|
||
|
||
async function hashStream(reader: NodeJS.ReadableStream): Promise<string> {
|
||
const hash = crypto.createHash('sha256')
|
||
for await (const chunk of reader) hash.update(chunk)
|
||
return hash.digest('hex')
|
||
}
|
||
|
||
function required(name: string): string {
|
||
const value = String(process.env[name] || '').trim()
|
||
if (!value) {
|
||
console.error(`缺少环境变量:${name}`)
|
||
process.exit(2)
|
||
}
|
||
return value
|
||
}
|
||
|
||
function readConcurrency(): number {
|
||
const value = Number(process.env.STORAGE_MIGRATION_CONCURRENCY || 8)
|
||
return Number.isInteger(value) && value > 0 ? Math.min(value, 32) : 8
|
||
}
|