add admin user last used support
This commit is contained in:
@@ -68,6 +68,7 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
|
|||||||
return &AdminUser{
|
return &AdminUser{
|
||||||
User: *base,
|
User: *base,
|
||||||
Notes: u.Notes,
|
Notes: u.Notes,
|
||||||
|
LastUsedAt: u.LastUsedAt,
|
||||||
GroupRates: u.GroupRates,
|
GroupRates: u.GroupRates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ type User struct {
|
|||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
User
|
User
|
||||||
|
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
// GroupRates 用户专属分组倍率配置
|
// GroupRates 用户专属分组倍率配置
|
||||||
// map[groupID]rateMultiplier
|
// map[groupID]rateMultiplier
|
||||||
GroupRates map[int64]float64 `json:"group_rates,omitempty"`
|
GroupRates map[int64]float64 `json:"group_rates,omitempty"`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
|
|||||||
|
|
||||||
lastLoginAt := time.Date(2026, time.April, 20, 10, 0, 0, 0, time.UTC)
|
lastLoginAt := time.Date(2026, time.April, 20, 10, 0, 0, 0, time.UTC)
|
||||||
lastActiveAt := lastLoginAt.Add(15 * time.Minute)
|
lastActiveAt := lastLoginAt.Add(15 * time.Minute)
|
||||||
|
lastUsedAt := lastLoginAt.Add(45 * time.Minute)
|
||||||
|
|
||||||
out := UserFromServiceAdmin(&service.User{
|
out := UserFromServiceAdmin(&service.User{
|
||||||
ID: 42,
|
ID: 42,
|
||||||
@@ -22,11 +23,14 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
|
|||||||
Status: service.StatusActive,
|
Status: service.StatusActive,
|
||||||
LastLoginAt: &lastLoginAt,
|
LastLoginAt: &lastLoginAt,
|
||||||
LastActiveAt: &lastActiveAt,
|
LastActiveAt: &lastActiveAt,
|
||||||
|
LastUsedAt: &lastUsedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
require.NotNil(t, out)
|
require.NotNil(t, out)
|
||||||
require.NotNil(t, out.LastLoginAt)
|
require.NotNil(t, out.LastLoginAt)
|
||||||
require.NotNil(t, out.LastActiveAt)
|
require.NotNil(t, out.LastActiveAt)
|
||||||
|
require.NotNil(t, out.LastUsedAt)
|
||||||
require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second)
|
require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second)
|
||||||
require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second)
|
require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second)
|
||||||
|
require.WithinDuration(t, lastUsedAt, *out.LastUsedAt, time.Second)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,51 +299,6 @@ func normalizeEmailAuthIdentitySubject(email string) string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
|
||||||
result := make(map[int64]*time.Time, len(userIDs))
|
|
||||||
if len(userIDs) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
if r.sql == nil {
|
|
||||||
return nil, fmt.Errorf("sql executor is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := r.sql.QueryContext(ctx, `
|
|
||||||
SELECT user_id, MAX(created_at) AS last_used_at
|
|
||||||
FROM usage_logs
|
|
||||||
WHERE user_id = ANY($1)
|
|
||||||
GROUP BY user_id
|
|
||||||
`, pq.Array(userIDs))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
userID int64
|
|
||||||
lastUsedAt time.Time
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&userID, &lastUsedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ts := lastUsedAt.UTC()
|
|
||||||
result[userID] = &ts
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
|
||||||
latestByUserID, err := r.GetLatestUsedAtByUserIDs(ctx, []int64{userID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return latestByUserID[userID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *userRepository) Delete(ctx context.Context, id int64) error {
|
func (r *userRepository) Delete(ctx context.Context, id int64) error {
|
||||||
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
|
affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -469,6 +424,10 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
|||||||
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
|
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
|
||||||
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
|
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
|
||||||
|
|
||||||
|
if sortBy == "last_used_at" {
|
||||||
|
return userLastUsedAtOrder(sortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
var field string
|
var field string
|
||||||
defaultField := true
|
defaultField := true
|
||||||
nullsLastField := false
|
nullsLastField := false
|
||||||
@@ -530,6 +489,72 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector)
|
|||||||
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
|
return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||||
|
result := make(map[int64]*time.Time, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if r.sql == nil {
|
||||||
|
return nil, fmt.Errorf("sql executor is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT user_id, MAX(created_at) AS last_used_at
|
||||||
|
FROM usage_logs
|
||||||
|
WHERE user_id = ANY($1)
|
||||||
|
GROUP BY user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, pq.Array(userIDs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
userID int64
|
||||||
|
lastUsedAt time.Time
|
||||||
|
)
|
||||||
|
if scanErr := rows.Scan(&userID, &lastUsedAt); scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
ts := lastUsedAt.UTC()
|
||||||
|
result[userID] = &ts
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||||
|
latestByUserID, err := r.GetLatestUsedAtByUserIDs(ctx, []int64{userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return latestByUserID[userID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userLastUsedAtOrder(sortOrder string) []func(*entsql.Selector) {
|
||||||
|
orderExpr := func(direction, nulls string, tieOrder func(string) string) func(*entsql.Selector) {
|
||||||
|
return func(s *entsql.Selector) {
|
||||||
|
subquery := fmt.Sprintf("(SELECT MAX(created_at) FROM usage_logs WHERE user_id = %s)", s.C(dbuser.FieldID))
|
||||||
|
s.OrderExpr(entsql.Expr(subquery + " " + direction + " NULLS " + nulls))
|
||||||
|
s.OrderBy(tieOrder(s.C(dbuser.FieldID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sortOrder == pagination.SortOrderAsc {
|
||||||
|
return []func(*entsql.Selector){
|
||||||
|
orderExpr("ASC", "FIRST", entsql.Asc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []func(*entsql.Selector){
|
||||||
|
orderExpr("DESC", "LAST", entsql.Desc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters
|
// filterUsersByAttributes returns user IDs that match ALL the given attribute filters
|
||||||
func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) {
|
func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) {
|
||||||
if len(attrs) == 0 {
|
if len(attrs) == 0 {
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (s *UserRepoSuite) mustInsertUsageLog(userID int64, createdAt time.Time) {
|
||||||
|
s.T().Helper()
|
||||||
|
|
||||||
|
account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "usage-log-account"})
|
||||||
|
apiKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: userID})
|
||||||
|
|
||||||
|
_, err := integrationDB.ExecContext(
|
||||||
|
s.ctx,
|
||||||
|
`INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at)
|
||||||
|
VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`,
|
||||||
|
userID,
|
||||||
|
apiKey.ID,
|
||||||
|
account.ID,
|
||||||
|
createdAt.UTC(),
|
||||||
|
)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserRepoSuite) TestListWithFilters_SortByEmailAsc() {
|
func (s *UserRepoSuite) TestListWithFilters_SortByEmailAsc() {
|
||||||
s.mustCreateUser(&service.User{Email: "z-last@example.com", Username: "z-user"})
|
s.mustCreateUser(&service.User{Email: "z-last@example.com", Username: "z-user"})
|
||||||
s.mustCreateUser(&service.User{Email: "a-first@example.com", Username: "a-user"})
|
s.mustCreateUser(&service.User{Email: "a-first@example.com", Username: "a-user"})
|
||||||
@@ -119,4 +137,49 @@ func (s *UserRepoSuite) TestListWithFilters_SortByLastActiveAtAsc() {
|
|||||||
s.Require().Equal("nil-active@example.com", users[2].Email)
|
s.Require().Equal("nil-active@example.com", users[2].Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserRepoSuite) TestGetLatestUsedAtByUserIDs_UsesUsageLogs() {
|
||||||
|
older := time.Now().Add(-4 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
newer := time.Now().Add(-90 * time.Minute).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
userWithUsage := s.mustCreateUser(&service.User{Email: "usage-source@example.com"})
|
||||||
|
userWithoutUsage := s.mustCreateUser(&service.User{Email: "usage-missing@example.com"})
|
||||||
|
s.mustInsertUsageLog(userWithUsage.ID, older)
|
||||||
|
s.mustInsertUsageLog(userWithUsage.ID, newer)
|
||||||
|
|
||||||
|
got, err := s.repo.GetLatestUsedAtByUserIDs(s.ctx, []int64{userWithUsage.ID, userWithoutUsage.ID})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Contains(got, userWithUsage.ID)
|
||||||
|
s.Require().NotContains(got, userWithoutUsage.ID)
|
||||||
|
s.Require().NotNil(got[userWithUsage.ID])
|
||||||
|
s.Require().True(got[userWithUsage.ID].Equal(newer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserRepoSuite) TestListWithFilters_SortByLastUsedAtDesc_UsesUsageLogsNotLastActiveAt() {
|
||||||
|
lastUsedOlder := time.Now().Add(-6 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
lastUsedNewer := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second)
|
||||||
|
lastActiveVeryRecent := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
|
||||||
|
|
||||||
|
nilUsage := s.mustCreateUser(&service.User{Email: "nil-last-used@example.com"})
|
||||||
|
wrongSource := s.mustCreateUser(&service.User{
|
||||||
|
Email: "active-not-usage@example.com",
|
||||||
|
LastActiveAt: &lastActiveVeryRecent,
|
||||||
|
})
|
||||||
|
rightSource := s.mustCreateUser(&service.User{Email: "usage-wins@example.com"})
|
||||||
|
|
||||||
|
s.mustInsertUsageLog(wrongSource.ID, lastUsedOlder)
|
||||||
|
s.mustInsertUsageLog(rightSource.ID, lastUsedNewer)
|
||||||
|
|
||||||
|
users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
SortBy: "last_used_at",
|
||||||
|
SortOrder: "desc",
|
||||||
|
}, service.UserListFilters{})
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Len(users, 3)
|
||||||
|
s.Require().Equal(rightSource.ID, users[0].ID)
|
||||||
|
s.Require().Equal(wrongSource.ID, users[1].ID)
|
||||||
|
s.Require().Equal(nilUsage.ID, users[2].ID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserRepoSortSuiteSmoke(_ *testing.T) {}
|
func TestUserRepoSortSuiteSmoke(_ *testing.T) {}
|
||||||
|
|||||||
@@ -557,6 +557,20 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if len(users) > 0 {
|
||||||
|
userIDs := make([]int64, 0, len(users))
|
||||||
|
for i := range users {
|
||||||
|
userIDs = append(userIDs, users[i].ID)
|
||||||
|
}
|
||||||
|
lastUsedByUserID, latestErr := s.userRepo.GetLatestUsedAtByUserIDs(ctx, userIDs)
|
||||||
|
if latestErr != nil {
|
||||||
|
logger.LegacyPrintf("service.admin", "failed to load user last_used_at in batch: err=%v", latestErr)
|
||||||
|
} else {
|
||||||
|
for i := range users {
|
||||||
|
users[i].LastUsedAt = lastUsedByUserID[users[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 批量加载用户专属分组倍率
|
// 批量加载用户专属分组倍率
|
||||||
if s.userGroupRateRepo != nil && len(users) > 0 {
|
if s.userGroupRateRepo != nil && len(users) > 0 {
|
||||||
if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok {
|
if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok {
|
||||||
@@ -601,6 +615,12 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
lastUsedAt, latestErr := s.userRepo.GetLatestUsedAtByUserID(ctx, id)
|
||||||
|
if latestErr != nil {
|
||||||
|
logger.LegacyPrintf("service.admin", "failed to load user last_used_at: user_id=%d err=%v", id, latestErr)
|
||||||
|
} else {
|
||||||
|
user.LastUsedAt = lastUsedAt
|
||||||
|
}
|
||||||
// 加载用户专属分组倍率
|
// 加载用户专属分组倍率
|
||||||
if s.userGroupRateRepo != nil {
|
if s.userGroupRateRepo != nil {
|
||||||
rates, err := s.userGroupRateRepo.GetByUserID(ctx, id)
|
rates, err := s.userGroupRateRepo.GetByUserID(ctx, id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -16,6 +17,8 @@ type userRepoStubForListUsers struct {
|
|||||||
users []User
|
users []User
|
||||||
err error
|
err error
|
||||||
listWithFiltersParams pagination.PaginationParams
|
listWithFiltersParams pagination.PaginationParams
|
||||||
|
lastUsedByUserID map[int64]*time.Time
|
||||||
|
lastUsedErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pagination.PaginationParams, _ UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||||
@@ -32,6 +35,26 @@ func (s *userRepoStubForListUsers) ListWithFilters(_ context.Context, params pag
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userRepoStubForListUsers) GetLatestUsedAtByUserIDs(_ context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||||
|
if s.lastUsedErr != nil {
|
||||||
|
return nil, s.lastUsedErr
|
||||||
|
}
|
||||||
|
result := make(map[int64]*time.Time, len(userIDs))
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if ts, ok := s.lastUsedByUserID[userID]; ok {
|
||||||
|
result[userID] = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userRepoStubForListUsers) GetLatestUsedAtByUserID(_ context.Context, userID int64) (*time.Time, error) {
|
||||||
|
if s.lastUsedErr != nil {
|
||||||
|
return nil, s.lastUsedErr
|
||||||
|
}
|
||||||
|
return s.lastUsedByUserID[userID], nil
|
||||||
|
}
|
||||||
|
|
||||||
type userGroupRateRepoStubForListUsers struct {
|
type userGroupRateRepoStubForListUsers struct {
|
||||||
batchCalls int
|
batchCalls int
|
||||||
singleCall []int64
|
singleCall []int64
|
||||||
@@ -130,3 +153,21 @@ func TestAdminService_ListUsers_PassesSortParams(t *testing.T) {
|
|||||||
SortOrder: "ASC",
|
SortOrder: "ASC",
|
||||||
}, userRepo.listWithFiltersParams)
|
}, userRepo.listWithFiltersParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminService_ListUsers_PopulatesLastUsedAt(t *testing.T) {
|
||||||
|
lastUsed := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Second)
|
||||||
|
userRepo := &userRepoStubForListUsers{
|
||||||
|
users: []User{{ID: 101, Email: "u@example.com"}},
|
||||||
|
lastUsedByUserID: map[int64]*time.Time{
|
||||||
|
101: &lastUsed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &adminServiceImpl{userRepo: userRepo}
|
||||||
|
|
||||||
|
users, total, err := svc.ListUsers(context.Background(), 1, 20, UserListFilters{}, "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), total)
|
||||||
|
require.Len(t, users, 1)
|
||||||
|
require.NotNil(t, users[0].LastUsedAt)
|
||||||
|
require.WithinDuration(t, lastUsed, *users[0].LastUsedAt, time.Second)
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type User struct {
|
|||||||
SignupSource string
|
SignupSource string
|
||||||
LastLoginAt *time.Time
|
LastLoginAt *time.Time
|
||||||
LastActiveAt *time.Time
|
LastActiveAt *time.Time
|
||||||
|
LastUsedAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export interface User {
|
|||||||
export interface AdminUser extends User {
|
export interface AdminUser extends User {
|
||||||
// 管理员备注(普通用户接口不返回)
|
// 管理员备注(普通用户接口不返回)
|
||||||
notes: string
|
notes: string
|
||||||
|
last_used_at?: string | null
|
||||||
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
||||||
group_rates?: Record<number, number>
|
group_rates?: Record<number, number>
|
||||||
// 当前并发数(仅管理员列表接口返回)
|
// 当前并发数(仅管理员列表接口返回)
|
||||||
|
|||||||
@@ -461,6 +461,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-last_used_at="{ value }">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ value ? formatDateTime(value) : '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-last_active_at="{ value }">
|
<template #cell-last_active_at="{ value }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
{{ value ? formatDateTime(value) : '-' }}
|
{{ value ? formatDateTime(value) : '-' }}
|
||||||
@@ -713,6 +719,7 @@ const allColumns = computed<Column[]>(() => [
|
|||||||
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
{ key: 'concurrency', label: t('admin.users.columns.concurrency'), sortable: true },
|
||||||
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.users.columns.status'), sortable: true },
|
||||||
{ key: 'last_login_at', label: t('admin.users.columns.lastLogin'), sortable: true },
|
{ key: 'last_login_at', label: t('admin.users.columns.lastLogin'), sortable: true },
|
||||||
|
{ key: 'last_used_at', label: t('admin.users.columns.lastUsed'), sortable: true },
|
||||||
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), sortable: true },
|
{ key: 'last_active_at', label: t('admin.users.columns.lastActive'), sortable: true },
|
||||||
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
|
{ key: 'created_at', label: t('admin.users.columns.created'), sortable: true },
|
||||||
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
{ key: 'actions', label: t('admin.users.columns.actions'), sortable: false }
|
||||||
@@ -801,7 +808,7 @@ const searchQuery = ref('')
|
|||||||
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
const USER_SORT_STORAGE_KEY = 'admin-users-table-sort'
|
||||||
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' } => {
|
||||||
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
|
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
|
||||||
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'last_login_at', 'last_active_at', 'created_at'])
|
const sortable = new Set(['email', 'id', 'username', 'role', 'balance', 'concurrency', 'status', 'last_login_at', 'last_used_at', 'last_active_at', 'created_at'])
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
const raw = localStorage.getItem(USER_SORT_STORAGE_KEY)
|
||||||
if (!raw) return fallback
|
if (!raw) return fallback
|
||||||
|
|||||||
162
frontend/src/views/admin/__tests__/UsersView.spec.ts
Normal file
162
frontend/src/views/admin/__tests__/UsersView.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import type { AdminUser } from '@/types'
|
||||||
|
import UsersView from '../UsersView.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
listUsers,
|
||||||
|
getAllGroups,
|
||||||
|
getBatchUsersUsage,
|
||||||
|
listEnabledDefinitions,
|
||||||
|
getBatchUserAttributes
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
listUsers: vi.fn(),
|
||||||
|
getAllGroups: vi.fn(),
|
||||||
|
getBatchUsersUsage: vi.fn(),
|
||||||
|
listEnabledDefinitions: vi.fn(),
|
||||||
|
getBatchUserAttributes: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin', () => ({
|
||||||
|
adminAPI: {
|
||||||
|
users: {
|
||||||
|
list: listUsers,
|
||||||
|
toggleStatus: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
getAll: getAllGroups
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
getBatchUsersUsage
|
||||||
|
},
|
||||||
|
userAttributes: {
|
||||||
|
listEnabledDefinitions,
|
||||||
|
getBatchUserAttributes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createAdminUser = (): AdminUser => ({
|
||||||
|
id: 42,
|
||||||
|
username: 'scoped-user',
|
||||||
|
email: 'scoped@example.com',
|
||||||
|
role: 'user',
|
||||||
|
balance: 0,
|
||||||
|
concurrency: 1,
|
||||||
|
status: 'active',
|
||||||
|
allowed_groups: [],
|
||||||
|
balance_notify_enabled: false,
|
||||||
|
balance_notify_threshold: null,
|
||||||
|
balance_notify_extra_emails: [],
|
||||||
|
created_at: '2026-04-17T00:00:00Z',
|
||||||
|
updated_at: '2026-04-17T00:00:00Z',
|
||||||
|
notes: '',
|
||||||
|
last_login_at: '2026-04-16T01:00:00Z',
|
||||||
|
last_active_at: '2026-04-16T02:00:00Z',
|
||||||
|
last_used_at: '2026-04-17T02:00:00Z',
|
||||||
|
current_concurrency: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = {
|
||||||
|
props: ['columns', 'data'],
|
||||||
|
emits: ['sort'],
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<div data-test="columns">{{ columns.map(col => col.key).join(',') }}</div>
|
||||||
|
<button data-test="sort-last-used" @click="$emit('sort', 'last_used_at', 'desc')">sort</button>
|
||||||
|
<div v-for="row in data" :key="row.id">
|
||||||
|
<slot name="cell-last_used_at" :value="row.last_used_at" :row="row" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin UsersView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
|
||||||
|
listUsers.mockReset()
|
||||||
|
getAllGroups.mockReset()
|
||||||
|
getBatchUsersUsage.mockReset()
|
||||||
|
listEnabledDefinitions.mockReset()
|
||||||
|
getBatchUserAttributes.mockReset()
|
||||||
|
|
||||||
|
listUsers.mockResolvedValue({
|
||||||
|
items: [createAdminUser()],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
pages: 1
|
||||||
|
})
|
||||||
|
getAllGroups.mockResolvedValue([])
|
||||||
|
getBatchUsersUsage.mockResolvedValue({ stats: {} })
|
||||||
|
listEnabledDefinitions.mockResolvedValue([])
|
||||||
|
getBatchUserAttributes.mockResolvedValue({ values: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows last_used_at column and requests last_used_at sort', async () => {
|
||||||
|
const wrapper = mount(UsersView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AppLayout: { template: '<div><slot /></div>' },
|
||||||
|
TablePageLayout: {
|
||||||
|
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||||
|
},
|
||||||
|
DataTable: DataTableStub,
|
||||||
|
Pagination: true,
|
||||||
|
ConfirmDialog: true,
|
||||||
|
EmptyState: true,
|
||||||
|
GroupBadge: true,
|
||||||
|
Select: true,
|
||||||
|
UserAttributesConfigModal: true,
|
||||||
|
UserConcurrencyCell: true,
|
||||||
|
UserCreateModal: true,
|
||||||
|
UserEditModal: true,
|
||||||
|
UserApiKeysModal: true,
|
||||||
|
UserAllowedGroupsModal: true,
|
||||||
|
UserBalanceModal: true,
|
||||||
|
UserBalanceHistoryModal: true,
|
||||||
|
GroupReplaceModal: true,
|
||||||
|
Icon: true,
|
||||||
|
Teleport: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('[data-test="columns"]').text()).toContain('last_used_at')
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="sort-last-used"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listUsers).toHaveBeenLastCalledWith(
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
expect.objectContaining({
|
||||||
|
sort_by: 'last_used_at',
|
||||||
|
sort_order: 'desc'
|
||||||
|
}),
|
||||||
|
expect.any(Object)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user