feat(admin-users): 支持按创建时间排序

This commit is contained in:
Entropy.Xu
2026-05-25 12:21:58 +08:00
parent 505d9fd8bc
commit aaad113190
13 changed files with 401 additions and 20 deletions
@@ -417,6 +417,7 @@ async fn resolve_admin_user_selection(
group_id: filters
.as_ref()
.and_then(|filters| filters.group_id.clone()),
..Default::default()
})
.await
.map_err(|_| "用户数据不可用".to_string())?
@@ -37,6 +37,13 @@ pub(in super::super) async fn build_admin_list_users_response(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let sort_by = query_param_value(request_context.query_string(), "sort_by")
.and_then(|value| aether_data::repository::users::UserExportSortBy::parse(&value))
.unwrap_or_default();
let sort_order = query_param_value(request_context.query_string(), "sort_order")
.and_then(|value| aether_data::repository::users::UserExportSortOrder::parse(&value))
.unwrap_or_default();
let query = aether_data::repository::users::UserExportListQuery {
skip,
limit,
@@ -44,6 +51,8 @@ pub(in super::super) async fn build_admin_list_users_response(
is_active,
search,
group_id,
sort_by,
sort_order,
};
let (paged_rows_result, total_result) = tokio::join!(
state.list_export_users_page(&query),
@@ -15,7 +15,7 @@ use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
use axum::body::Body;
use axum::routing::{any, delete, get, patch, post, put};
use axum::{extract::Request, Router};
use chrono::Utc;
use chrono::{TimeZone, Utc};
use http::StatusCode;
use serde_json::json;
@@ -37,6 +37,16 @@ fn sample_admin_user_with_role(
role: &str,
email: &str,
username: &str,
) -> StoredUserAuthRecord {
sample_admin_user_with_role_and_created_at(user_id, role, email, username, Utc::now())
}
fn sample_admin_user_with_role_and_created_at(
user_id: &str,
role: &str,
email: &str,
username: &str,
created_at: chrono::DateTime<Utc>,
) -> StoredUserAuthRecord {
StoredUserAuthRecord::new(
user_id.to_string(),
@@ -51,7 +61,7 @@ fn sample_admin_user_with_role(
Some(json!(["gpt-4.1"])),
true,
false,
Some(Utc::now()),
Some(created_at),
Some(Utc::now()),
)
.expect("user should build")
@@ -206,6 +216,113 @@ fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthA
.expect("api key snapshot should build")
}
#[tokio::test]
async fn gateway_sorts_admin_users_by_created_at() {
let oldest = Utc
.with_ymd_and_hms(2026, 1, 10, 0, 0, 0)
.single()
.expect("valid timestamp");
let middle = Utc
.with_ymd_and_hms(2026, 2, 10, 0, 0, 0)
.single()
.expect("valid timestamp");
let newest = Utc
.with_ymd_and_hms(2026, 3, 10, 0, 0, 0)
.single()
.expect("valid timestamp");
let user_repository = Arc::new(
InMemoryUserReadRepository::seed_auth_users(vec![
sample_admin_user_with_role_and_created_at(
"user-old",
"user",
"old@example.com",
"old",
oldest,
),
sample_admin_user_with_role_and_created_at(
"user-middle",
"user",
"middle@example.com",
"middle",
middle,
),
sample_admin_user_with_role_and_created_at(
"user-new",
"user",
"new@example.com",
"new",
newest,
),
])
.with_export_users(vec![
sample_admin_export_user_with("user", true, "user-old", "old@example.com", "old"),
sample_admin_export_user_with(
"user",
true,
"user-middle",
"middle@example.com",
"middle",
),
sample_admin_export_user_with("user", true, "user-new", "new@example.com", "new"),
]),
);
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_user_reader_for_tests(
user_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let desc_response = client
.get(format!(
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=desc"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(desc_response.status(), StatusCode::OK);
let desc_payload: serde_json::Value = desc_response.json().await.expect("json should parse");
let desc_ids = desc_payload["items"]
.as_array()
.expect("items should be array")
.iter()
.map(|item| item["id"].as_str().expect("id should be string"))
.collect::<Vec<_>>();
assert_eq!(desc_ids, vec!["user-new", "user-middle", "user-old"]);
let asc_response = client
.get(format!(
"{gateway_url}/api/admin/users?skip=0&limit=10&sort_by=created_at&sort_order=asc"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(asc_response.status(), StatusCode::OK);
let asc_payload: serde_json::Value = asc_response.json().await.expect("json should parse");
let asc_ids = asc_payload["items"]
.as_array()
.expect("items should be array")
.iter()
.map(|item| item["id"].as_str().expect("id should be string"))
.collect::<Vec<_>>();
assert_eq!(asc_ids, vec!["user-old", "user-middle", "user-new"]);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
@@ -7,8 +7,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::DataLayerError;
@@ -409,6 +409,34 @@ fn filter_memory_export_rows(
.contains(&search)
});
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
let created_at_by_id = repository
.auth_by_id
.read()
.expect("user repository lock")
.iter()
.filter_map(|(user_id, user)| {
user.created_at
.map(|created_at| (user_id.clone(), created_at.timestamp_millis()))
})
.collect::<BTreeMap<_, _>>();
rows.sort_by(|left, right| {
let primary = created_at_by_id
.get(&left.id)
.cmp(&created_at_by_id.get(&right.id));
let ordered = if query.sort_order.is_desc() {
primary.reverse()
} else {
primary
};
ordered.then_with(|| left.id.cmp(&right.id))
});
}
UserExportSortBy::Id => {
rows.sort_by(|left, right| left.id.cmp(&right.id));
}
}
rows
}
@@ -2682,6 +2710,7 @@ mod tests {
is_active: Some(true),
search: None,
group_id: None,
..Default::default()
})
.await
.expect("paged export should succeed");
@@ -12,5 +12,6 @@ pub use types::{
normalize_user_group_name, StoredUserAuthRecord, StoredUserExportRow, StoredUserGroup,
StoredUserGroupMember, StoredUserGroupMembership, StoredUserOAuthLinkSummary,
StoredUserPreferenceRecord, StoredUserSessionRecord, StoredUserSummary, UpsertUserGroupRecord,
UserExportListQuery, UserExportSummary, UserReadRepository,
UserExportListQuery, UserExportSortBy, UserExportSortOrder, UserExportSummary,
UserReadRepository,
};
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
@@ -313,8 +313,24 @@ impl UserReadRepository for MysqlUserReadRepository {
.push_bind(pattern)
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC LIMIT ")
.push(" LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
})?)
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::{error::SqlxResultExt, DataLayerError};
@@ -1001,8 +1001,24 @@ WHERE user_group_members.user_id IN (
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC OFFSET ")
.push(" OFFSET ")
.push_bind(i64::try_from(query.skip).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export skip: {}", query.skip))
})?)
@@ -6,8 +6,8 @@ use super::types::{
normalize_user_group_name, LdapAuthUserProvisioningOutcome, StoredUserAuthRecord,
StoredUserExportRow, StoredUserGroup, StoredUserGroupMember, StoredUserGroupMembership,
StoredUserOAuthLinkSummary, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSummary,
UserReadRepository,
StoredUserSummary, UpsertUserGroupRecord, UserExportListQuery, UserExportSortBy,
UserExportSummary, UserReadRepository,
};
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
@@ -313,8 +313,24 @@ impl UserReadRepository for SqliteUserReadRepository {
.push_bind(pattern)
.push(")");
}
match query.sort_by {
UserExportSortBy::CreatedAt => {
builder
.push(" ORDER BY created_at ")
.push(if query.sort_order.is_desc() {
"DESC"
} else {
"ASC"
})
.push(", id ASC");
}
UserExportSortBy::Id => {
builder.push(" ORDER BY id ASC");
}
}
builder
.push(" ORDER BY id ASC LIMIT ")
.push(" LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
})?)
@@ -2199,6 +2215,7 @@ INSERT INTO users (
is_active: Some(true),
search: None,
group_id: None,
..Default::default()
})
.await
.expect("export page should load");
@@ -642,6 +642,46 @@ pub struct UserExportListQuery {
pub is_active: Option<bool>,
pub search: Option<String>,
pub group_id: Option<String>,
pub sort_by: UserExportSortBy,
pub sort_order: UserExportSortOrder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortBy {
#[default]
Id,
CreatedAt,
}
impl UserExportSortBy {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"created_at" => Some(Self::CreatedAt),
"id" => Some(Self::Id),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserExportSortOrder {
#[default]
Asc,
Desc,
}
impl UserExportSortOrder {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"asc" => Some(Self::Asc),
"desc" => Some(Self::Desc),
_ => None,
}
}
pub fn is_desc(self) -> bool {
matches!(self, Self::Desc)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, cachedRequestMock } = vi.hoisted(() => ({
getMock: vi.fn(),
cachedRequestMock: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
}))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
},
}))
vi.mock('@/utils/cache', () => ({
cachedRequest: cachedRequestMock,
}))
import { usersApi } from '@/api/users'
describe('usersApi admin list query', () => {
beforeEach(() => {
getMock.mockReset()
cachedRequestMock.mockClear()
getMock.mockResolvedValue({
data: {
items: [],
total: 0,
skip: 0,
limit: 20,
has_more: false,
},
})
})
it('passes creation-time sort parameters to the admin users endpoint', async () => {
await usersApi.getAllUsersPage({
skip: 20,
limit: 10,
sort_by: 'created_at',
sort_order: 'desc',
})
expect(getMock).toHaveBeenCalledWith('/api/admin/users', {
params: {
skip: 20,
limit: 10,
sort_by: 'created_at',
sort_order: 'desc',
},
})
})
})
+8
View File
@@ -6,6 +6,8 @@ import type { BillingPlan, UserPlanEntitlement } from './billing'
export type UserRole = 'admin' | 'audit_admin' | 'user'
export type ListPolicyMode = 'inherit' | 'unrestricted' | 'specific' | 'deny_all'
export type RateLimitPolicyMode = 'inherit' | 'system' | 'custom'
export type AdminUserSortBy = 'created_at'
export type AdminUserSortOrder = 'asc' | 'desc'
export type FeatureSettings = Record<string, unknown>
export interface UserGroupSummary {
@@ -261,6 +263,8 @@ export interface GetAllUsersOptions {
role?: UserRole
is_active?: boolean
group_id?: string
sort_by?: AdminUserSortBy
sort_order?: AdminUserSortOrder
skip?: number
limit?: number
cacheTtlMs?: number
@@ -298,6 +302,8 @@ export const usersApi = {
if (options.role) params.role = options.role
if (options.is_active !== undefined) params.is_active = options.is_active ? 'true' : 'false'
if (options.group_id) params.group_id = options.group_id
if (options.sort_by) params.sort_by = options.sort_by
if (options.sort_order) params.sort_order = options.sort_order
if (options.skip !== undefined) params.skip = options.skip
if (options.limit !== undefined) params.limit = options.limit
@@ -309,6 +315,8 @@ export const usersApi = {
options.role ?? '',
options.is_active ?? '',
options.group_id ?? '',
options.sort_by ?? '',
options.sort_order ?? '',
options.skip ?? '',
options.limit ?? '',
options.cacheKeySuffix ?? '',
+4
View File
@@ -14,6 +14,8 @@ import {
type UserBatchActionRequest,
type UserBatchActionResponse,
type UserRole,
type AdminUserSortBy,
type AdminUserSortOrder,
type UserGroup,
type UserGroupMember,
type UpsertUserGroupRequest,
@@ -40,6 +42,8 @@ export const useUsersStore = defineStore('users', () => {
role?: UserRole
is_active?: boolean
group_id?: string
sort_by?: AdminUserSortBy
sort_order?: AdminUserSortOrder
skip?: number
limit?: number
} = {}) {
+77 -6
View File
@@ -41,8 +41,8 @@
</div>
</div>
<!-- 筛选器 -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-40 flex-1">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="users-search-mobile"
@@ -110,6 +110,22 @@
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="sortOption"
>
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in userSortOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@@ -201,6 +217,23 @@
</SelectContent>
</Select>
<div class="xl:hidden">
<Select v-model="sortOption">
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
<SelectValue placeholder="排序" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in userSortOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
@@ -317,9 +350,17 @@
<TableHead class="w-[170px] h-12 font-semibold">
统计/限速
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
<SortableTableHead
class="w-[110px] h-12 font-semibold"
column-key="created_at"
:active-key="sortBy"
:direction="sortOrder"
default-direction="desc"
title="按创建时间排序"
@sort="handleTableSort"
>
创建时间
</TableHead>
</SortableTableHead>
<SortableTableHead
class="w-[180px] h-12 font-semibold"
column-key="status"
@@ -1470,7 +1511,18 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useAuthStore } from '@/stores/auth'
import { usersApi, type User, type ApiKey, type UserSession, type UserBatchActionResponse, type UserBatchSelectionFilters, type UserGroup, type AdminUserPlanEntitlement } from '@/api/users'
import {
usersApi,
type User,
type ApiKey,
type UserSession,
type UserBatchActionResponse,
type UserBatchSelectionFilters,
type UserGroup,
type AdminUserPlanEntitlement,
type AdminUserSortBy,
type AdminUserSortOrder,
} from '@/api/users'
import { formatSessionMeta } from '@/types/session'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { adminBillingPlansApi, type BillingEntitlement, type BillingPlan } from '@/api/billing'
@@ -1598,6 +1650,7 @@ const searchQuery = ref('')
const filterRole = ref<'all' | User['role']>('all')
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
const filterGroup = ref('all')
const sortOption = ref<'default' | 'created_at_desc' | 'created_at_asc'>('default')
const userGroups = ref<UserGroup[]>([])
const userRoleFilterOptions = [
{ value: 'all', label: '全部角色' },
@@ -1610,6 +1663,17 @@ const userStatusFilterOptions = [
{ value: 'active', label: '活跃' },
{ value: 'inactive', label: '禁用' },
]
const userSortOptions = [
{ value: 'default', label: '默认排序' },
{ value: 'created_at_desc', label: '创建时间 新到旧' },
{ value: 'created_at_asc', label: '创建时间 旧到新' },
]
const sortBy = computed<AdminUserSortBy | null>(() =>
sortOption.value === 'default' ? null : 'created_at'
)
const sortOrder = computed<AdminUserSortOrder>(() =>
sortOption.value === 'created_at_asc' ? 'asc' : 'desc'
)
const currentPage = ref(1)
const pageSize = ref(20)
@@ -1659,7 +1723,7 @@ const grantableBillingPlans = computed(() =>
)
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
watch([searchQuery, filterRole, filterStatus, filterGroup, sortOption], () => {
currentPage.value = 1
resetBatchSelection()
void refreshUsers()
@@ -1691,6 +1755,8 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
role: filterRole.value === 'all' ? undefined : filterRole.value,
is_active: filterStatus.value === 'all' ? undefined : filterStatus.value === 'active',
group_id: filterGroup.value === 'all' ? undefined : filterGroup.value,
sort_by: sortBy.value ?? undefined,
sort_order: sortBy.value ? sortOrder.value : undefined,
skip: (currentPage.value - 1) * pageSize.value,
limit: pageSize.value,
}),
@@ -1701,6 +1767,11 @@ async function refreshUsers(options: { preferCache?: boolean } = {}) {
})
}
function handleTableSort(payload: { key: string, direction: AdminUserSortOrder }): void {
if (payload.key !== 'created_at') return
sortOption.value = payload.direction === 'asc' ? 'created_at_asc' : 'created_at_desc'
}
function handlePageChange(page: number): void {
currentPage.value = page
void refreshUsers({ preferCache: true })