feat: 支持注销用户并展示普通用户使用情况
- 新增管理员注销用户接口,要求输入用户名二次确认 - 注销时清理用户当前图片、视频、文件、标签、会话和自身日志 - 保留备份任务、管理员审计数据和全局配置 - 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮 - 补充注销流程与用量统计测试
This commit is contained in:
1 parent
dd0005b7ba
commit
ccfdac2e1b
8 files changed
+380
-10
No files matched your search
+124
-1
@@ -1,10 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatFileSize } from '~/composables/useFileSize'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
role: 'admin' | 'user'
|
role: 'admin' | 'user'
|
||||||
created_at: string
|
created_at: string
|
||||||
password_reset: number
|
password_reset: number
|
||||||
|
usage?: {
|
||||||
|
imageBytes: number
|
||||||
|
videoBytes: number
|
||||||
|
fileBytes: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -21,6 +28,18 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const users = ref<AdminUser[]>([])
|
const users = ref<AdminUser[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const deleteTarget = ref<AdminUser | null>(null)
|
||||||
|
const deleteConfirm = ref('')
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const canDelete = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
deleteTarget.value
|
||||||
|
&& deleteTarget.value.role !== 'admin'
|
||||||
|
&& deleteConfirm.value.trim() === deleteTarget.value.username
|
||||||
|
&& !deleting.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!isAdmin.value) return
|
if (!isAdmin.value) return
|
||||||
@@ -57,6 +76,51 @@ async function clearPassword(user: AdminUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeleteDialog(user: AdminUser) {
|
||||||
|
if (user.role === 'admin') return
|
||||||
|
deleteTarget.value = user
|
||||||
|
deleteConfirm.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteDialog() {
|
||||||
|
if (deleting.value) return
|
||||||
|
deleteTarget.value = null
|
||||||
|
deleteConfirm.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteOpenChange(open: boolean) {
|
||||||
|
if (!open) closeDeleteDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser() {
|
||||||
|
const target = deleteTarget.value
|
||||||
|
if (!target || !canDelete.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/users/${target.id}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: { confirmation: deleteConfirm.value.trim() }
|
||||||
|
})
|
||||||
|
toast.add({
|
||||||
|
title: t('users.userDeleted', { name: target.username }),
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
deleteTarget.value = null
|
||||||
|
deleteConfirm.value = ''
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
toast.add({
|
||||||
|
title: getFetchErrorMessage(error, t('users.deleteFailed')),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
try {
|
try {
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
@@ -150,6 +214,18 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="user.role !== 'admin'"
|
||||||
|
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
|
||||||
|
>
|
||||||
|
<span>{{ t('users.usageImages') }} {{ formatFileSize(user.usage?.imageBytes ?? 0) }}</span>
|
||||||
|
<span>{{ t('users.usageVideos') }} {{ formatFileSize(user.usage?.videoBytes ?? 0) }}</span>
|
||||||
|
<span>{{ t('users.usageFiles') }} {{ formatFileSize(user.usage?.fileBytes ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="user.role !== 'admin'"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="user.password_reset === 1"
|
v-if="user.password_reset === 1"
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -159,15 +235,62 @@ onMounted(async () => {
|
|||||||
{{ t('users.resetPending') }}
|
{{ t('users.resetPending') }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UButton
|
<UButton
|
||||||
v-else
|
v-if="user.password_reset !== 1"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="warning"
|
color="warning"
|
||||||
size="sm"
|
size="sm"
|
||||||
:label="t('users.clearPassword')"
|
:label="t('users.clearPassword')"
|
||||||
@click="clearPassword(user)"
|
@click="clearPassword(user)"
|
||||||
/>
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="outline"
|
||||||
|
color="error"
|
||||||
|
size="sm"
|
||||||
|
:label="t('users.deleteUser')"
|
||||||
|
@click="openDeleteDialog(user)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
:open="Boolean(deleteTarget)"
|
||||||
|
:title="t('users.deleteTitle')"
|
||||||
|
:description="t('users.deleteWarning')"
|
||||||
|
@update:open="onDeleteOpenChange"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
{{ t('users.deleteConfirmLabel', { name: deleteTarget?.username ?? '' }) }}
|
||||||
|
</p>
|
||||||
|
<UInput
|
||||||
|
v-model="deleteConfirm"
|
||||||
|
:placeholder="deleteTarget?.username"
|
||||||
|
class="w-full"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="closeDeleteDialog"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="t('users.deleteSubmit')"
|
||||||
|
color="error"
|
||||||
|
:loading="deleting"
|
||||||
|
:disabled="!canDelete"
|
||||||
|
@click="deleteUser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+12
-1
@@ -1283,14 +1283,25 @@
|
|||||||
,
|
,
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"subtitle": "Review accounts and clear passwords; users set a new one on next login",
|
"subtitle": "Review accounts, clear passwords, or delete users",
|
||||||
"adminOnly": "Admins only",
|
"adminOnly": "Admins only",
|
||||||
"roleAdmin": "Admin",
|
"roleAdmin": "Admin",
|
||||||
"roleUser": "User",
|
"roleUser": "User",
|
||||||
"clearPassword": "Clear password",
|
"clearPassword": "Clear password",
|
||||||
|
"deleteUser": "Delete user",
|
||||||
|
"usageImages": "Images",
|
||||||
|
"usageVideos": "Videos",
|
||||||
|
"usageFiles": "Files",
|
||||||
"resetPending": "Reset pending",
|
"resetPending": "Reset pending",
|
||||||
"passwordCleared": "Cleared password for {name}",
|
"passwordCleared": "Cleared password for {name}",
|
||||||
"clearFailed": "Operation failed",
|
"clearFailed": "Operation failed",
|
||||||
|
"deleteTitle": "Delete user?",
|
||||||
|
"deleteWarning": "This permanently deletes the user's current images, videos, files, tags, and login sessions. Admin and other users' data are not affected. This cannot be undone.",
|
||||||
|
"deleteConfirmLabel": "Type the username {name} to confirm",
|
||||||
|
"deleteSubmit": "Delete user",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"userDeleted": "Deleted user {name}",
|
||||||
|
"adminProtected": "Admin accounts cannot be cleared or deleted",
|
||||||
"loadFailed": "Failed to load users"
|
"loadFailed": "Failed to load users"
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
|
|||||||
+12
-1
@@ -1286,14 +1286,25 @@
|
|||||||
,
|
,
|
||||||
"users": {
|
"users": {
|
||||||
"title": "用户管理",
|
"title": "用户管理",
|
||||||
"subtitle": "查看账号并清除密码,用户下次登录时重新设置",
|
"subtitle": "查看账号、清除密码或注销用户",
|
||||||
"adminOnly": "仅管理员可访问此页面",
|
"adminOnly": "仅管理员可访问此页面",
|
||||||
"roleAdmin": "管理员",
|
"roleAdmin": "管理员",
|
||||||
"roleUser": "普通用户",
|
"roleUser": "普通用户",
|
||||||
"clearPassword": "清除密码",
|
"clearPassword": "清除密码",
|
||||||
|
"deleteUser": "注销用户",
|
||||||
|
"usageImages": "图片",
|
||||||
|
"usageVideos": "视频",
|
||||||
|
"usageFiles": "文件",
|
||||||
"resetPending": "待重置密码",
|
"resetPending": "待重置密码",
|
||||||
"passwordCleared": "已清除 {name} 的密码",
|
"passwordCleared": "已清除 {name} 的密码",
|
||||||
"clearFailed": "操作失败",
|
"clearFailed": "操作失败",
|
||||||
|
"deleteTitle": "注销用户?",
|
||||||
|
"deleteWarning": "将永久删除该用户当前的图片、视频、文件、标签和登录会话。管理员数据和其他用户数据不受影响,此操作不可恢复。",
|
||||||
|
"deleteConfirmLabel": "输入用户名 {name} 确认注销",
|
||||||
|
"deleteSubmit": "确认注销",
|
||||||
|
"deleteFailed": "注销失败",
|
||||||
|
"userDeleted": "已注销用户 {name}",
|
||||||
|
"adminProtected": "管理员账号不可清除密码或注销",
|
||||||
"loadFailed": "读取用户列表失败"
|
"loadFailed": "读取用户列表失败"
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { requireAdminAuth } from '../../../../utils/access'
|
||||||
|
import { createApiError } from '../../../../utils/api-error'
|
||||||
|
import { logActivity } from '../../../../utils/activity-log'
|
||||||
|
import { findUserById } from '../../../../utils/db'
|
||||||
|
import { deleteUserCurrentData } from '../../../../utils/user-storage'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const operator = await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
|
const target = Number.isInteger(id) ? findUserById(id) : null
|
||||||
|
if (!target) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
|
||||||
|
}
|
||||||
|
if (target.role === 'admin') {
|
||||||
|
createApiError(event, 'FORBIDDEN', '不允许注销管理员账号', 403)
|
||||||
|
}
|
||||||
|
if (target.id === operator.id) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '不能注销当前登录账号', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ confirmation?: string }>(event).catch(() => null)
|
||||||
|
if (body?.confirmation?.trim() !== target.username) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '请输入用户名确认注销', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteUserCurrentData(target.id)
|
||||||
|
if (result.failed.length) {
|
||||||
|
createApiError(
|
||||||
|
event,
|
||||||
|
'CONFLICT',
|
||||||
|
`有 ${result.failed.length} 个媒体删除失败,账号未注销,请重试`,
|
||||||
|
409
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(event, {
|
||||||
|
action: 'delete',
|
||||||
|
key: `users/${target.id}`,
|
||||||
|
originalName: '已注销用户',
|
||||||
|
source: 'admin',
|
||||||
|
userId: operator.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, ...result }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'USER_STORAGE_CLEAR_IN_PROGRESS') {
|
||||||
|
createApiError(event, 'CONFLICT', '用户数据正在清理,请勿重复操作', 409)
|
||||||
|
}
|
||||||
|
if (error.message === 'USER_STORAGE_CLEAR_ACTIVE_JOB') {
|
||||||
|
createApiError(event, 'CONFLICT', '该用户有正在运行的任务,请完成后再注销', 409)
|
||||||
|
}
|
||||||
|
if (error.message === 'ADMIN_USER_DELETE_FORBIDDEN') {
|
||||||
|
createApiError(event, 'FORBIDDEN', '不允许注销管理员账号', 403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { requireAdminAuth } from '../../../utils/access'
|
import { requireAdminAuth } from '../../../utils/access'
|
||||||
import { listAdminUsers } from '../../../utils/db'
|
import { listAdminUsersWithUsage } from '../../../utils/db'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requireAdminAuth(event)
|
await requireAdminAuth(event)
|
||||||
return { users: listAdminUsers() }
|
return { users: listAdminUsersWithUsage() }
|
||||||
})
|
})
|
||||||
@@ -459,6 +459,12 @@ export interface AdminUserRow {
|
|||||||
password_reset: number
|
password_reset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUserUsage {
|
||||||
|
imageBytes: number
|
||||||
|
videoBytes: number
|
||||||
|
fileBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 管理员用户列表 */
|
/** 管理员用户列表 */
|
||||||
export function listAdminUsers(): AdminUserRow[] {
|
export function listAdminUsers(): AdminUserRow[] {
|
||||||
ensurePasswordResetColumn()
|
ensurePasswordResetColumn()
|
||||||
@@ -468,6 +474,42 @@ export function listAdminUsers(): AdminUserRow[] {
|
|||||||
`).all() as unknown as 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 {
|
export function clearUserPassword(userId: number): void {
|
||||||
ensurePasswordResetColumn()
|
ensurePasswordResetColumn()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
getDb,
|
getDb,
|
||||||
insertActivityLog,
|
insertActivityLog,
|
||||||
insertUser
|
insertUser,
|
||||||
|
listAdminUsersWithUsage
|
||||||
} from './db'
|
} from './db'
|
||||||
import {
|
import {
|
||||||
listImageIndexRowsForUser,
|
listImageIndexRowsForUser,
|
||||||
@@ -16,7 +17,7 @@ import { ensureDefaultBackends } from './storage-backends'
|
|||||||
import { getActiveBackend } from './storage/resolver'
|
import { getActiveBackend } from './storage/resolver'
|
||||||
import { putImage } from './storage'
|
import { putImage } from './storage'
|
||||||
import { createTag } from './tags'
|
import { createTag } from './tags'
|
||||||
import { clearUserStorage } from './user-storage'
|
import { clearUserStorage, deleteUserCurrentData } from './user-storage'
|
||||||
import {
|
import {
|
||||||
createBackupJob,
|
createBackupJob,
|
||||||
getBackupJob,
|
getBackupJob,
|
||||||
@@ -142,4 +143,81 @@ describe('clearUserStorage', () => {
|
|||||||
await expect(fs.access(archiveA)).rejects.toThrow()
|
await expect(fs.access(archiveA)).rejects.toThrow()
|
||||||
await expect(fs.access(archiveB)).resolves.toBeUndefined()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -34,6 +34,11 @@ export interface ClearUserStorageResult {
|
|||||||
failed: Array<{ key: string, message: string }>
|
failed: Array<{ key: string, message: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClearUserStorageOptions {
|
||||||
|
/** 用户注销时保留备份任务,避免影响管理员的备份数据 */
|
||||||
|
deleteBackupJobs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteObject(
|
async function deleteObject(
|
||||||
backend: StorageBackend,
|
backend: StorageBackend,
|
||||||
key: string
|
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)) {
|
if (activeUsers.has(userId)) {
|
||||||
throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS')
|
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')
|
throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB')
|
||||||
}
|
}
|
||||||
|
|
||||||
const backupJobs = listAllBackupJobsForUser(userId)
|
const backupJobs = deleteBackupJobs ? listAllBackupJobsForUser(userId) : []
|
||||||
const rows = listImageIndexRowsForUser(userId)
|
const rows = listImageIndexRowsForUser(userId)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
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) {
|
for (const job of backupJobs) {
|
||||||
await removeBackupJobArtifacts(job)
|
await removeBackupJobArtifacts(job)
|
||||||
}
|
}
|
||||||
@@ -171,3 +180,38 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
|
|||||||
activeUsers.delete(userId)
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user