diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2f18c64e..d88c110c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -68,6 +68,7 @@ func UserFromServiceAdmin(u *service.User) *AdminUser { return &AdminUser{ User: *base, Notes: u.Notes, + LastUsedAt: u.LastUsedAt, GroupRates: u.GroupRates, } } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 72fce1fe..15b8548a 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -36,7 +36,8 @@ type User struct { type AdminUser struct { User - Notes string `json:"notes"` + Notes string `json:"notes"` + LastUsedAt *time.Time `json:"last_used_at"` // GroupRates 用户专属分组倍率配置 // map[groupID]rateMultiplier GroupRates map[int64]float64 `json:"group_rates,omitempty"` diff --git a/backend/internal/handler/dto/user_mapper_activity_test.go b/backend/internal/handler/dto/user_mapper_activity_test.go index 668f886c..1e362fba 100644 --- a/backend/internal/handler/dto/user_mapper_activity_test.go +++ b/backend/internal/handler/dto/user_mapper_activity_test.go @@ -13,6 +13,7 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) { lastLoginAt := time.Date(2026, time.April, 20, 10, 0, 0, 0, time.UTC) lastActiveAt := lastLoginAt.Add(15 * time.Minute) + lastUsedAt := lastLoginAt.Add(45 * time.Minute) out := UserFromServiceAdmin(&service.User{ ID: 42, @@ -22,11 +23,14 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) { Status: service.StatusActive, LastLoginAt: &lastLoginAt, LastActiveAt: &lastActiveAt, + LastUsedAt: &lastUsedAt, }) require.NotNil(t, out) require.NotNil(t, out.LastLoginAt) require.NotNil(t, out.LastActiveAt) + require.NotNil(t, out.LastUsedAt) require.WithinDuration(t, lastLoginAt, *out.LastLoginAt, time.Second) require.WithinDuration(t, lastActiveAt, *out.LastActiveAt, time.Second) + require.WithinDuration(t, lastUsedAt, *out.LastUsedAt, time.Second) } diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 7378611d..25d3f1d6 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -299,51 +299,6 @@ func normalizeEmailAuthIdentitySubject(email string) string { 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 { affected, err := r.client.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx) if err != nil { @@ -469,6 +424,10 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector) sortBy := strings.ToLower(strings.TrimSpace(params.SortBy)) sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc) + if sortBy == "last_used_at" { + return userLastUsedAtOrder(sortOrder) + } + var field string defaultField := true 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)} } +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 func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) { if len(attrs) == 0 { diff --git a/backend/internal/repository/user_repo_sort_integration_test.go b/backend/internal/repository/user_repo_sort_integration_test.go index 8abef45a..e2445d5b 100644 --- a/backend/internal/repository/user_repo_sort_integration_test.go +++ b/backend/internal/repository/user_repo_sort_integration_test.go @@ -10,6 +10,24 @@ import ( "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() { s.mustCreateUser(&service.User{Email: "z-last@example.com", Username: "z-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) } +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) {} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 79840e5b..10b85f76 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -557,6 +557,20 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi if err != nil { 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 batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok { @@ -601,6 +615,12 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) if err != nil { 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 { rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) diff --git a/backend/internal/service/admin_service_list_users_test.go b/backend/internal/service/admin_service_list_users_test.go index ceeb52c2..657616c4 100644 --- a/backend/internal/service/admin_service_list_users_test.go +++ b/backend/internal/service/admin_service_list_users_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" @@ -16,6 +17,8 @@ type userRepoStubForListUsers struct { users []User err error listWithFiltersParams pagination.PaginationParams + lastUsedByUserID map[int64]*time.Time + lastUsedErr 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 } +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 { batchCalls int singleCall []int64 @@ -130,3 +153,21 @@ func TestAdminService_ListUsers_PassesSortParams(t *testing.T) { SortOrder: "ASC", }, 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) +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index d8b5325c..fa04d95e 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -26,6 +26,7 @@ type User struct { SignupSource string LastLoginAt *time.Time LastActiveAt *time.Time + LastUsedAt *time.Time CreatedAt time.Time UpdatedAt time.Time diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a4b2277b..5a2e3184 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -93,6 +93,7 @@ export interface User { export interface AdminUser extends User { // 管理员备注(普通用户接口不返回) notes: string + last_used_at?: string | null // 用户专属分组倍率配置 (group_id -> rate_multiplier) group_rates?: Record // 当前并发数(仅管理员列表接口返回) diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 55a8f2d8..07c9d437 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -461,6 +461,12 @@ + +