feat: 支持注销用户并展示普通用户使用情况

- 新增管理员注销用户接口,要求输入用户名二次确认
- 注销时清理用户当前图片、视频、文件、标签、会话和自身日志
- 保留备份任务、管理员审计数据和全局配置
- 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮
- 补充注销流程与用量统计测试
This commit is contained in:
tianmo committed 2026-10-19 17:48:44 +08:00
1 parent dd0005b7ba
commit ccfdac2e1b
8 files changed
+394 -24

No files matched your search

+42
View File
@@ -459,6 +459,12 @@ export interface AdminUserRow {
password_reset: number
}
export interface AdminUserUsage {
imageBytes: number
videoBytes: number
fileBytes: number
}
/** 管理员用户列表 */
export function listAdminUsers(): AdminUserRow[] {
ensurePasswordResetColumn()
@@ -468,6 +474,42 @@ export function listAdminUsers(): AdminUserRow[] {
`).all() as unknown as AdminUserRow[]
}
/** 管理员用户列表,附带图片、视频、文件占用 */
export function listAdminUsersWithUsage(): Array<AdminUserRow & { usage: AdminUserUsage }> {
const usage = new Map<number, AdminUserUsage>()
const rows = getDb().prepare(`
SELECT user_id, media_type, COALESCE(SUM(size), 0) AS bytes
FROM images
WHERE user_id IS NOT NULL
GROUP BY user_id, media_type
`).all() as Array<{ user_id: number, media_type: string, bytes: number }>
for (const row of rows) {
const entry = usage.get(row.user_id) ?? {
imageBytes: 0,
videoBytes: 0,
fileBytes: 0
}
if (row.media_type === 'video') {
entry.videoBytes = Number(row.bytes)
} else if (row.media_type === 'file') {
entry.fileBytes = Number(row.bytes)
} else {
entry.imageBytes = Number(row.bytes)
}
usage.set(row.user_id, entry)
}
return listAdminUsers().map(user => ({
...user,
usage: usage.get(user.id) ?? {
imageBytes: 0,
videoBytes: 0,
fileBytes: 0
}
}))
}
/** 清空密码并标记需要重置 */
export function clearUserPassword(userId: number): void {
ensurePasswordResetColumn()
+80 -2
View File
@@ -5,7 +5,8 @@ import { describe, expect, it } from 'vitest'
import {
getDb,
insertActivityLog,
insertUser
insertUser,
listAdminUsersWithUsage
} from './db'
import {
listImageIndexRowsForUser,
@@ -16,7 +17,7 @@ import { ensureDefaultBackends } from './storage-backends'
import { getActiveBackend } from './storage/resolver'
import { putImage } from './storage'
import { createTag } from './tags'
import { clearUserStorage } from './user-storage'
import { clearUserStorage, deleteUserCurrentData } from './user-storage'
import {
createBackupJob,
getBackupJob,
@@ -142,4 +143,81 @@ describe('clearUserStorage', () => {
await expect(fs.access(archiveA)).rejects.toThrow()
await expect(fs.access(archiveB)).resolves.toBeUndefined()
})
it('deletes current user data without deleting backups or admin audit data', async () => {
ensureDefaultBackends()
const now = new Date().toISOString()
const admin = insertUser({
username: 'delete-admin',
passwordHash: 'admin-hash',
role: 'admin',
createdAt: now
})
const user = insertUser({
username: 'delete-user',
passwordHash: 'user-hash',
role: 'user',
createdAt: now
})
const key = 'images/2026/10/delete-user.webp'
const target = await getActiveBackend()
await putImage(key, new Uint8Array([1]), {
originalName: 'delete-user.webp',
uploadedAt: now,
contentType: 'image/webp',
size: 1,
userId: user,
mediaType: 'image'
})
const tag = createTag(user, 'delete tag')
getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)')
.run(key, tag.id)
getDb().prepare(`
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
`).run('delete-session', user, 'hash', now, now)
insertActivityLog({
action: 'upload',
key,
originalName: 'delete-user.webp',
size: 1,
contentType: 'image/webp',
source: 'web',
userId: user
})
insertActivityLog({
action: 'settings',
key: `users/${user}/clear-password`,
originalName: 'delete-user',
size: 0,
contentType: '',
source: 'admin',
userId: admin
})
const job = createBackupJob({ type: 'full_export', userId: user })
markJobDone(job.id, {})
const listed = listAdminUsersWithUsage().find(item => item.id === user)
expect(listed?.usage.imageBytes).toBe(1)
const result = await deleteUserCurrentData(user)
expect(result.failed).toEqual([])
expect(result.deletedMedia).toBe(1)
expect(result.deletedTags).toBe(1)
expect(await target.head(key)).toBeNull()
expect(getDb().prepare('SELECT 1 FROM users WHERE id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM sessions WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM images WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM tags WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM activity_logs WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare(`
SELECT original_name FROM activity_logs
WHERE user_id = ? AND key = ?
`).get(admin, `users/${user}/clear-password`)).toEqual({ original_name: '已注销用户' })
expect(getDb().prepare('SELECT 1 FROM backup_jobs WHERE id = ?').get(job.id)).toBeDefined()
await expect(deleteUserCurrentData(admin)).rejects.toThrow('ADMIN_USER_DELETE_FORBIDDEN')
})
})
+47 -3
View File
@@ -34,6 +34,11 @@ export interface ClearUserStorageResult {
failed: Array<{ key: string, message: string }>
}
export interface ClearUserStorageOptions {
/** 用户注销时保留备份任务,避免影响管理员的备份数据 */
deleteBackupJobs?: boolean
}
async function deleteObject(
backend: StorageBackend,
key: string
@@ -73,7 +78,11 @@ async function removeBackupJobArtifacts(job: BackupJob): Promise<void> {
}
}
export async function clearUserStorage(userId: number): Promise<ClearUserStorageResult> {
export async function clearUserStorage(
userId: number,
options: ClearUserStorageOptions = {}
): Promise<ClearUserStorageResult> {
const deleteBackupJobs = options.deleteBackupJobs !== false
if (activeUsers.has(userId)) {
throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS')
}
@@ -94,7 +103,7 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB')
}
const backupJobs = listAllBackupJobsForUser(userId)
const backupJobs = deleteBackupJobs ? listAllBackupJobsForUser(userId) : []
const rows = listImageIndexRowsForUser(userId)
for (const row of rows) {
try {
@@ -158,7 +167,7 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
}
}
if (!result.failed.length) {
if (!result.failed.length && deleteBackupJobs) {
for (const job of backupJobs) {
await removeBackupJobArtifacts(job)
}
@@ -171,3 +180,38 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
activeUsers.delete(userId)
}
}
/** 管理员注销用户:清理当前有效数据,但保留备份与管理员审计数据 */
export async function deleteUserCurrentData(
userId: number
): Promise<ClearUserStorageResult> {
const db = getDb()
const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?')
.get(userId) as { id: number, username: string, role: string } | undefined
if (!target) throw new Error('USER_NOT_FOUND')
if (target.role === 'admin') throw new Error('ADMIN_USER_DELETE_FORBIDDEN')
// 先终止会话,避免清理过程中继续上传;失败时用户仍可重新登录。
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
const result = await clearUserStorage(userId, { deleteBackupJobs: false })
if (result.failed.length) return result
db.exec('BEGIN')
try {
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
db.prepare(`
UPDATE activity_logs
SET original_name = '已注销用户'
WHERE key LIKE ?
`).run(`users/${userId}/%`)
db.prepare('DELETE FROM users WHERE id = ?').run(userId)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
return result
}