add admin user last used support

This commit is contained in:
IanShaw027
2026-04-21 00:22:17 +08:00
parent beeab54ae3
commit bf3ef2d19a
11 changed files with 373 additions and 47 deletions

View File

@@ -68,6 +68,7 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
return &AdminUser{
User: *base,
Notes: u.Notes,
LastUsedAt: u.LastUsedAt,
GroupRates: u.GroupRates,
}
}

View File

@@ -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"`

View File

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

View File

@@ -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 {

View File

@@ -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) {}

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ type User struct {
SignupSource string
LastLoginAt *time.Time
LastActiveAt *time.Time
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time