93 lines
5.2 KiB
JavaScript
93 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
/**
|
|||
|
|
* ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28)
|
|||
|
|
*
|
|||
|
|
* 分三档(**判定依据只有"路径/文件名模式 + 年龄"**,因为无法可靠区分"AI 写的"):
|
|||
|
|
* T1 明确垃圾/平台产物 → 直接清(白名单精确匹配)
|
|||
|
|
* T2 临时脚本(一次性、无复用价值)→ 超过 N 天未修改才清(默认 90 天)
|
|||
|
|
* T3 其余一切(文档/表格/图片/视频/数据/目录)→ **永不自动删**,只统计
|
|||
|
|
*
|
|||
|
|
* 安全:默认 dry-run;--apply 时一律 `mv` 到 <userRoot>/trash/<日期>-ws-cleanup/(保留 30 天可恢复);
|
|||
|
|
* 仅当该用户 ws ≥ --threshold MB 才动手(默认 2048 MB)。
|
|||
|
|
*
|
|||
|
|
* 用法:
|
|||
|
|
* node ws-cleanup.cjs # dry-run + 画像
|
|||
|
|
* node ws-cleanup.cjs --apply # 执行(含阈值判断)
|
|||
|
|
* node ws-cleanup.cjs --apply --threshold 1024 --days 60
|
|||
|
|
* node ws-cleanup.cjs --user-id <uuid>
|
|||
|
|
*/
|
|||
|
|
const { execFileSync } = require('node:child_process')
|
|||
|
|
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
|
|||
|
|
const { join, extname, basename } = require('node:path')
|
|||
|
|
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
|||
|
|
|
|||
|
|
const argv = process.argv.slice(2)
|
|||
|
|
const APPLY = argv.includes('--apply')
|
|||
|
|
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
|
|||
|
|
const THRESHOLD_MB = num('--threshold', 2048)
|
|||
|
|
const DAYS = num('--days', 90)
|
|||
|
|
const idx = argv.indexOf('--user-id')
|
|||
|
|
const ONLY = idx >= 0 ? argv[idx + 1] : ''
|
|||
|
|
const STAMP = new Date().toISOString().slice(0, 10)
|
|||
|
|
const CUTOFF = Date.now() - DAYS * 86400_000
|
|||
|
|
|
|||
|
|
// T1:平台产物/明确垃圾(精确名或后缀);T2:一次性脚本(仅"顶层脚本文件"才算,避免误伤项目目录里的源码)
|
|||
|
|
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
|
|||
|
|
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
|
|||
|
|
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
|
|||
|
|
|
|||
|
|
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
|||
|
|
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
|||
|
|
|
|||
|
|
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
|||
|
|
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
|
|||
|
|
|
|||
|
|
for (const u of users) {
|
|||
|
|
const ws = join(u.home_dir, '..', 'ws')
|
|||
|
|
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
|
|||
|
|
const wsBytes = duKB(ws)
|
|||
|
|
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
|
|||
|
|
let otherBytes = 0, otherCount = 0
|
|||
|
|
|
|||
|
|
for (const entry of readdirSync(ws)) {
|
|||
|
|
const p = join(ws, entry)
|
|||
|
|
let st
|
|||
|
|
try { st = statSync(p) } catch { continue }
|
|||
|
|
if (st.isDirectory()) {
|
|||
|
|
if (T1_DIRS.includes(entry)) { t1.push({ p, size: duKB(p) }); continue }
|
|||
|
|
otherBytes += duKB(p); otherCount += 1 // 目录一律算用户资产(T3)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const ext = extname(entry).toLowerCase()
|
|||
|
|
if (T1_SUFFIX.includes(ext)) { t1.push({ p, size: st.size }); continue }
|
|||
|
|
if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) { t2.push({ p, size: st.size, mtime: st.mtime }); continue }
|
|||
|
|
otherBytes += st.size; otherCount += 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// .keep 豁免(档案 28):ws 顶层放一个 .keep 文件 → 该用户**跳过 T2**(一次性脚本不清理),T1 仍清
|
|||
|
|
const keepAll = existsSync(join(ws, '.keep'))
|
|||
|
|
if (keepAll && t2.length > 0) {
|
|||
|
|
console.log(` (.keep 已存在 → T2 保留 ${t2.length} 项,不清理)`)
|
|||
|
|
t2.length = 0
|
|||
|
|
}
|
|||
|
|
const t1B = t1.reduce((a, c) => a + c.size, 0), t2B = t2.reduce((a, c) => a + c.size, 0)
|
|||
|
|
const over = wsBytes >= THRESHOLD_MB * 1048576
|
|||
|
|
console.log(` ${u.username}: ws=${fmt(wsBytes)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | T1=${t1.length}项/${fmt(t1B)} | T2=${t2.length}项(>${DAYS}天)/${fmt(t2B)} | 资产=${otherCount}项/${fmt(otherBytes)}`)
|
|||
|
|
for (const c of t1) console.log(` T1 ${basename(c.p)} ${fmt(c.size)}`)
|
|||
|
|
for (const c of t2) console.log(` T2 ${basename(c.p)} ${fmt(c.size)} (mtime ${c.mtime.toISOString().slice(0, 10)})`)
|
|||
|
|
|
|||
|
|
if (APPLY && over && (t1.length + t2.length) > 0) {
|
|||
|
|
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-cleanup`)
|
|||
|
|
mkdirSync(trash, { recursive: true })
|
|||
|
|
for (const c of [...t1, ...t2]) {
|
|||
|
|
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
|
|||
|
|
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
|
|||
|
|
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
|||
|
|
}
|
|||
|
|
console.log(` → 已清 ${t1.length + t2.length} 项 / ${fmt(t1B + t2B)} → trash/${STAMP}-ws-cleanup(保留 30 天)`)
|
|||
|
|
} else if (APPLY && !over) {
|
|||
|
|
console.log(` (未超阈值,跳过清理)`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
db.close()
|
|||
|
|
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
|