feat: add server-side admin user pagination and align traffic report accounting

This commit is contained in:
2026-02-17 20:30:02 +08:00
parent 1eae645bfd
commit aed5dfdcb2
4 changed files with 378 additions and 154 deletions

View File

@@ -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);

View File

@@ -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);