From aed5dfdcb29b8f54cc0635f70bdbd0dbf2d00947 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Tue, 17 Feb 2026 20:30:02 +0800 Subject: [PATCH] feat: add server-side admin user pagination and align traffic report accounting --- backend/database.js | 193 ++++++++++++++++++++++++++++++++++++ backend/server.js | 81 +++++++++------ frontend/app.html | 23 +++-- frontend/app.js | 235 ++++++++++++++++++++++---------------------- 4 files changed, 378 insertions(+), 154 deletions(-) diff --git a/backend/database.js b/backend/database.js index 010c049..0ed11f9 100644 --- a/backend/database.js +++ b/backend/database.js @@ -886,6 +886,199 @@ const UserDB = { return db.prepare(query).all(...params); }, + queryAdminUsers(options = {}) { + const parsedPage = Number.parseInt(options.page, 10); + const parsedPageSize = Number.parseInt(options.pageSize, 10); + const safePage = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1; + const safePageSize = Number.isFinite(parsedPageSize) + ? Math.min(200, Math.max(1, parsedPageSize)) + : 20; + + const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : ''; + const role = ['all', 'admin', 'user'].includes(options.role) ? options.role : 'all'; + const status = ['all', 'active', 'banned', 'unverified', 'download_blocked'].includes(options.status) + ? options.status + : 'all'; + const storage = ['all', 'local', 'oss', 'local_only', 'oss_only', 'user_choice'].includes(options.storage) + ? options.storage + : 'all'; + const sort = [ + 'created_desc', + 'created_asc', + 'username_asc', + 'username_desc', + 'storage_usage_desc', + 'download_usage_desc' + ].includes(options.sort) + ? options.sort + : 'created_desc'; + + const whereClauses = ['1=1']; + const params = []; + + if (keyword) { + const likeKeyword = `%${keyword}%`; + whereClauses.push('(CAST(id AS TEXT) LIKE ? OR username LIKE ? OR email LIKE ?)'); + params.push(likeKeyword, likeKeyword, likeKeyword); + } + + if (role === 'admin') { + whereClauses.push('is_admin = 1'); + } else if (role === 'user') { + whereClauses.push('is_admin = 0'); + } + + if (status === 'banned') { + whereClauses.push('is_banned = 1'); + } else if (status === 'unverified') { + whereClauses.push('is_banned = 0 AND is_verified = 0'); + } else if (status === 'download_blocked') { + whereClauses.push(` + is_banned = 0 + AND is_verified = 1 + AND COALESCE(download_traffic_quota, 0) >= 0 + AND ( + COALESCE(download_traffic_quota, 0) = 0 + OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0) + ) + `); + } else if (status === 'active') { + whereClauses.push(` + is_banned = 0 + AND is_verified = 1 + AND ( + COALESCE(download_traffic_quota, -1) < 0 + OR ( + COALESCE(download_traffic_quota, 0) > 0 + AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0) + ) + ) + `); + } + + if (storage === 'local' || storage === 'oss') { + whereClauses.push("COALESCE(current_storage_type, 'oss') = ?"); + params.push(storage); + } else if (storage !== 'all') { + whereClauses.push("COALESCE(storage_permission, 'oss_only') = ?"); + params.push(storage); + } + + const defaultOssQuota = 1024 * 1024 * 1024; // 1GB + const storageUsageExpr = ` + CASE + WHEN COALESCE(current_storage_type, 'oss') = 'local' THEN + CASE + WHEN COALESCE(local_storage_quota, 0) > 0 + THEN (COALESCE(local_storage_used, 0) * 100.0 / COALESCE(local_storage_quota, 1)) + ELSE 0 + END + ELSE + CASE + WHEN COALESCE(oss_storage_quota, 0) > 0 + THEN (COALESCE(storage_used, 0) * 100.0 / COALESCE(oss_storage_quota, ${defaultOssQuota})) + ELSE 0 + END + END + `; + const downloadUsageExpr = ` + CASE + WHEN COALESCE(download_traffic_quota, 0) < 0 THEN 0 + WHEN COALESCE(download_traffic_quota, 0) = 0 THEN 100 + ELSE MIN( + 100, + COALESCE(download_traffic_used, 0) * 100.0 / MAX(COALESCE(download_traffic_quota, 0), 1) + ) + END + `; + + const orderByMap = { + created_desc: 'datetime(created_at) DESC, id DESC', + created_asc: 'datetime(created_at) ASC, id ASC', + username_asc: "LOWER(COALESCE(username, '')) ASC, id ASC", + username_desc: "LOWER(COALESCE(username, '')) DESC, id DESC", + storage_usage_desc: `${storageUsageExpr} DESC, id DESC`, + download_usage_desc: `${downloadUsageExpr} DESC, id DESC` + }; + const orderBySql = orderByMap[sort] || orderByMap.created_desc; + const whereSql = whereClauses.join(' AND '); + + const globalTotalRow = db.prepare('SELECT COUNT(*) AS total FROM users').get(); + const globalTotal = Number(globalTotalRow?.total || 0); + + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM users + WHERE ${whereSql} + `).get(...params); + const filteredTotal = Number(totalRow?.total || 0); + + const summaryRow = db.prepare(` + SELECT + COUNT(*) AS filtered_total, + SUM(CASE WHEN is_banned = 1 THEN 1 ELSE 0 END) AS banned, + SUM(CASE WHEN is_banned = 0 AND is_verified = 0 THEN 1 ELSE 0 END) AS unverified, + SUM( + CASE + WHEN is_banned = 0 + AND is_verified = 1 + AND COALESCE(download_traffic_quota, 0) >= 0 + AND ( + COALESCE(download_traffic_quota, 0) = 0 + OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0) + ) + THEN 1 ELSE 0 + END + ) AS download_blocked, + SUM( + CASE + WHEN is_banned = 0 + AND is_verified = 1 + AND ( + COALESCE(download_traffic_quota, -1) < 0 + OR ( + COALESCE(download_traffic_quota, 0) > 0 + AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0) + ) + ) + THEN 1 ELSE 0 + END + ) AS active + FROM users + WHERE ${whereSql} + `).get(...params); + + const totalPages = Math.max(1, Math.ceil(filteredTotal / safePageSize)); + const page = Math.min(Math.max(1, safePage), totalPages); + const offset = (page - 1) * safePageSize; + + const rows = db.prepare(` + SELECT * + FROM users + WHERE ${whereSql} + ORDER BY ${orderBySql} + LIMIT ? OFFSET ? + `).all(...params, safePageSize, offset); + + return { + rows, + pagination: { + page, + pageSize: safePageSize, + total: filteredTotal, + totalPages + }, + summary: { + global_total: Number.isFinite(globalTotal) ? globalTotal : 0, + filtered_total: Number(summaryRow?.filtered_total || 0), + active: Number(summaryRow?.active || 0), + banned: Number(summaryRow?.banned || 0), + unverified: Number(summaryRow?.unverified || 0), + download_blocked: Number(summaryRow?.download_blocked || 0) + } + }; + }, + // 删除用户 delete(id) { return db.prepare('DELETE FROM users WHERE id = ?').run(id); diff --git a/backend/server.js b/backend/server.js index 3c8f7b9..364a10c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1013,13 +1013,16 @@ const applyConfirmedDownloadTrafficFromLogTransaction = db.transaction((userId, : Math.min(trafficState.quota, trafficState.used + bytes); UserDB.update(userId, { download_traffic_used: nextUsed }); - DownloadTrafficReportDB.addUsage(userId, bytes, count > 0 ? count : 1, eventDate); + const addedBytes = Math.max(0, nextUsed - trafficState.used); + if (addedBytes > 0) { + DownloadTrafficReportDB.addUsage(userId, addedBytes, count > 0 ? count : 1, eventDate); + } const consumeResult = DownloadTrafficReservationDB.consumePendingBytes(userId, bytes); return { userId, confirmed: bytes, - added: Math.max(0, nextUsed - trafficState.used), + added: addedBytes, usedBefore: trafficState.used, usedAfter: nextUsed, consumedReserved: Number(consumeResult?.consumed || 0), @@ -7054,39 +7057,59 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, // 获取所有用户 app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { try { - const users = UserDB.getAll().map(user => { + const toAdminUserPayload = (u) => ({ + id: u.id, + username: u.username, + email: u.email, + is_admin: u.is_admin, + is_active: u.is_active, + is_verified: u.is_verified, + is_banned: u.is_banned, + has_oss_config: u.has_oss_config, + created_at: u.created_at, + storage_permission: u.storage_permission || 'oss_only', + current_storage_type: u.current_storage_type || 'oss', + local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, + local_storage_used: u.local_storage_used || 0, + oss_storage_quota: normalizeOssQuota(u.oss_storage_quota), + storage_used: u.storage_used || 0, + download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota), + download_traffic_used: normalizeDownloadTrafficUsed( + u.download_traffic_used, + normalizeDownloadTrafficQuota(u.download_traffic_quota) + ), + download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null, + download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none', + download_traffic_last_reset_at: u.download_traffic_last_reset_at || null + }); + + const hasPagedQuery = req.query?.paged === '1' + || ['page', 'pageSize', 'keyword', 'role', 'status', 'storage', 'sort'] + .some((key) => req.query && req.query[key] !== undefined); + + if (hasPagedQuery) { + const queryResult = UserDB.queryAdminUsers(req.query || {}); + const users = queryResult.rows.map((user) => { + const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list_page'); + return policyState?.user || user; + }); + + return res.json({ + success: true, + users: users.map(toAdminUserPayload), + pagination: queryResult.pagination, + summary: queryResult.summary + }); + } + + const users = UserDB.getAll().map((user) => { const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list'); return policyState?.user || user; }); res.json({ success: true, - users: users.map(u => ({ - id: u.id, - username: u.username, - email: u.email, - is_admin: u.is_admin, - is_active: u.is_active, - is_verified: u.is_verified, - is_banned: u.is_banned, - has_oss_config: u.has_oss_config, - created_at: u.created_at, - // 新增:存储相关字段 - storage_permission: u.storage_permission || 'oss_only', - current_storage_type: u.current_storage_type || 'oss', - local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, - local_storage_used: u.local_storage_used || 0, - oss_storage_quota: normalizeOssQuota(u.oss_storage_quota), - storage_used: u.storage_used || 0, - download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota), - download_traffic_used: normalizeDownloadTrafficUsed( - u.download_traffic_used, - normalizeDownloadTrafficQuota(u.download_traffic_quota) - ), - download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null, - download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none', - download_traffic_last_reset_at: u.download_traffic_last_reset_at || null - })) + users: users.map(toAdminUserPayload) }); } catch (error) { console.error('获取用户列表失败:', error); diff --git a/frontend/app.html b/frontend/app.html index 670e2f6..49c53e4 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -2698,6 +2698,9 @@ 按天用量明细(最近 {{ downloadTrafficReport.days }} 天) 峰值: {{ formatReportDateLabel(downloadTrafficReport.summary.peak_day.date) }} / {{ formatBytes(downloadTrafficReport.summary.peak_day.bytes_used || 0) }} +
+ 说明:预览/在线播放同样计入下载流量;OSS 明细来自访问日志,通常有 5-15 分钟延迟。 +
报表加载中... @@ -3522,11 +3525,11 @@
- @@ -3534,7 +3537,7 @@
- @@ -3544,7 +3547,7 @@
- @@ -3555,7 +3558,7 @@
- @@ -3566,7 +3569,7 @@
- @@ -3580,7 +3583,7 @@
- 总用户 {{ adminUsers.length }} + 总用户 {{ adminUsersGlobalCount }} 筛选后 {{ adminUsersFilteredCount }} 正常 {{ adminUserStats.active }} 封禁 {{ adminUserStats.banned }} @@ -3611,7 +3614,7 @@ - + {{ u.id }}
@@ -3703,7 +3706,7 @@
- 显示 {{ adminUsersPageStart }}-{{ adminUsersPageEnd }} 条,共 {{ adminUsersFilteredCount }} 条(总 {{ adminUsers.length }} 条) + 显示 {{ adminUsersPageStart }}-{{ adminUsersPageEnd }} 条,共 {{ adminUsersFilteredCount }} 条(总 {{ adminUsersGlobalCount }} 条)