- 前端 Vue3+Vite+Element Plus 按需引入,双主题(bright/dark)CSS 变量 - 三端响应式(桌面/平板/手机),登录/概览/检索/阈值/忽略/动态 6 页面 - 后端 Express + better-sqlite3,目录扫描 + demo 模式,清理原子写入 - Dockerfile 多阶段构建 + docker-compose(storage 只读挂载)
167 lines
5.4 KiB
JavaScript
167 lines
5.4 KiB
JavaScript
/**
|
||
* 预警清理原子逻辑(§3.4 / AC-7 原子性)
|
||
* 1. 读 package.json
|
||
* 2. 按 semver 排序保留最新 N 个
|
||
* 3. 删低版本 versions/time 记录
|
||
* 4. 修正 dist-tags(latest 指向已删版本时指向保留的最新)
|
||
* 5. 写回元数据
|
||
* 6. 物理删 .tgz
|
||
* 7. 记日志
|
||
* 8. 返回「删除数量、释放空间」
|
||
* 失败时不做任何文件修改(先备份→写临时文件→rename 提交)
|
||
*/
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import semverCompare from './semver.js'
|
||
|
||
export function cleanPackage(storageDir, pkgName, keep) {
|
||
const pkgDir = locatePackageDir(storageDir, pkgName)
|
||
if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` }
|
||
|
||
const pkgFile = path.join(pkgDir, 'package.json')
|
||
let meta
|
||
try {
|
||
meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
|
||
} catch {
|
||
return { ok: false, message: `读取 ${pkgName} 元数据失败` }
|
||
}
|
||
|
||
const versions = Object.keys(meta.versions || {})
|
||
if (!versions.length) return { ok: false, message: '包内没有可清理的版本' }
|
||
|
||
// 按 semver 数值排序(旧 → 新),保留最新 keep 个
|
||
const sorted = versions.sort((a, b) => semverCompare(a, b))
|
||
const toDelete = sorted.slice(0, Math.max(0, sorted.length - keep))
|
||
if (!toDelete.length) {
|
||
return { ok: false, message: '版本数未超过保留值,无需清理' }
|
||
}
|
||
|
||
// —— 准备提交内容 ——
|
||
const nextMeta = JSON.parse(JSON.stringify(meta))
|
||
const time = nextMeta.time || {}
|
||
const freed = []
|
||
for (const v of toDelete) {
|
||
const info = nextMeta.versions[v] || {}
|
||
delete nextMeta.versions[v]
|
||
delete time[v]
|
||
// 物理文件大小(用于释放空间统计)
|
||
const tgz = path.join(pkgDir, `${v}.tgz`)
|
||
try {
|
||
freed.push({ v, size: fs.statSync(tgz).size, tgz })
|
||
} catch {
|
||
freed.push({ v, size: info.dist?.unpackedSize || 0, tgz })
|
||
}
|
||
}
|
||
nextMeta.time = time
|
||
|
||
// —— 修正 dist-tags ——
|
||
const distTags = nextMeta['dist-tags'] || {}
|
||
const kept = sorted.slice(-keep)
|
||
for (const [tag, v] of Object.entries(distTags)) {
|
||
if (toDelete.includes(v)) {
|
||
distTags[tag] = kept[kept.length - 1]
|
||
}
|
||
}
|
||
nextMeta['dist-tags'] = distTags
|
||
|
||
// —— 原子提交:临时文件写入 → rename ——
|
||
const tmp = `${pkgFile}.clean.tmp`
|
||
try {
|
||
fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2))
|
||
fs.renameSync(tmp, pkgFile) // 同目录 rename 为原子操作
|
||
} catch (err) {
|
||
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
|
||
return { ok: false, message: `写回元数据失败:${err.message}` }
|
||
}
|
||
|
||
// —— 物理删除 tgz(元数据已提交,删除失败仅记录不阻塞) ——
|
||
const deletedBytes = freed.reduce((s, f) => s + f.size, 0)
|
||
for (const f of freed) {
|
||
try {
|
||
if (f.tgz && fs.existsSync(f.tgz)) fs.unlinkSync(f.tgz)
|
||
} catch {
|
||
/* 忽略单个文件删除失败 */
|
||
}
|
||
}
|
||
|
||
const freedText = `${(deletedBytes / 1024 / 1024).toFixed(1)} MB`
|
||
return {
|
||
ok: true,
|
||
pkgName,
|
||
deletedVersions: toDelete.join(', '),
|
||
deletedCount: toDelete.length,
|
||
freedSpace: freedText,
|
||
freedBytes: deletedBytes,
|
||
}
|
||
}
|
||
|
||
/** 删除单个版本(抽屉内单版本删除) */
|
||
export function deleteSingleVersion(storageDir, pkgName, version) {
|
||
const pkgDir = locatePackageDir(storageDir, pkgName)
|
||
if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` }
|
||
const pkgFile = path.join(pkgDir, 'package.json')
|
||
|
||
let meta
|
||
try {
|
||
meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
|
||
} catch {
|
||
return { ok: false, message: '读取元数据失败' }
|
||
}
|
||
if (!meta.versions || !meta.versions[version]) {
|
||
return { ok: false, message: `版本 ${version} 不存在` }
|
||
}
|
||
|
||
const nextMeta = JSON.parse(JSON.stringify(meta))
|
||
const info = nextMeta.versions[version]
|
||
delete nextMeta.versions[version]
|
||
if (nextMeta.time) delete nextMeta.time[version]
|
||
|
||
// 修正 dist-tags
|
||
const distTags = nextMeta['dist-tags'] || {}
|
||
const remaining = Object.keys(nextMeta.versions).sort((a, b) => semverCompare(a, b))
|
||
for (const [tag, v] of Object.entries(distTags)) {
|
||
if (v === version) distTags[tag] = remaining[remaining.length - 1] || version
|
||
}
|
||
nextMeta['dist-tags'] = distTags
|
||
|
||
const tmp = `${pkgFile}.clean.tmp`
|
||
try {
|
||
fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2))
|
||
fs.renameSync(tmp, pkgFile)
|
||
} catch (err) {
|
||
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
|
||
return { ok: false, message: `写回元数据失败:${err.message}` }
|
||
}
|
||
|
||
let size = info.dist?.unpackedSize || 0
|
||
const tgz = path.join(pkgDir, `${version}.tgz`)
|
||
try {
|
||
if (fs.existsSync(tgz)) {
|
||
size = fs.statSync(tgz).size
|
||
fs.unlinkSync(tgz)
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
return {
|
||
ok: true,
|
||
freedSpace: `${(size / 1024 / 1024).toFixed(1)} MB`,
|
||
}
|
||
}
|
||
|
||
/** 定位包目录(支持 scoped:@scope/name 嵌套目录) */
|
||
export function locatePackageDir(storageDir, pkgName) {
|
||
const parts = pkgName.split('/')
|
||
const candidates = []
|
||
if (parts.length === 2 && parts[0].startsWith('@')) {
|
||
candidates.push(path.join(storageDir, parts[0], parts[1]))
|
||
} else {
|
||
candidates.push(path.join(storageDir, parts[parts.length - 1]))
|
||
// 兼容 scoped 包以全名作为单一目录的情况
|
||
if (parts.length === 2) candidates.push(path.join(storageDir, pkgName))
|
||
}
|
||
for (const c of candidates) {
|
||
if (fs.existsSync(path.join(c, 'package.json'))) return c
|
||
}
|
||
return null
|
||
}
|