Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
bf3ef2d1
"docs/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "27cad10d3049df23da6db94de99f4a22c8c05911"
Commit
bf3ef2d1
authored
Apr 21, 2026
by
IanShaw027
Browse files
add admin user last used support
parent
beeab54a
Changes
11
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/dto/mappers.go
View file @
bf3ef2d1
...
@@ -68,6 +68,7 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
...
@@ -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
,
}
}
}
}
...
...
backend/internal/handler/dto/types.go
View file @
bf3ef2d1
...
@@ -36,7 +36,8 @@ type User struct {
...
@@ -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"`
...
...
backend/internal/handler/dto/user_mapper_activity_test.go
View file @
bf3ef2d1
...
@@ -13,6 +13,7 @@ func TestUserFromServiceAdmin_MapsActivityTimestamps(t *testing.T) {
...
@@ -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) {
...
@@ -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
)
}
}
backend/internal/repository/user_repo.go
View file @
bf3ef2d1
...
@@ -299,51 +299,6 @@ func normalizeEmailAuthIdentitySubject(email string) string {
...
@@ -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)
...
@@ -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)
...
@@ -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
{
...
...
backend/internal/repository/user_repo_sort_integration_test.go
View file @
bf3ef2d1
...
@@ -10,6 +10,24 @@ import (
...
@@ -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() {
...
@@ -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
)
{}
backend/internal/service/admin_service.go
View file @
bf3ef2d1
...
@@ -557,6 +557,20 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi
...
@@ -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)
...
@@ -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
)
...
...
backend/internal/service/admin_service_list_users_test.go
View file @
bf3ef2d1
...
@@ -6,6 +6,7 @@ import (
...
@@ -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 {
...
@@ -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
...
@@ -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) {
...
@@ -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
)
}
backend/internal/service/user.go
View file @
bf3ef2d1
...
@@ -26,6 +26,7 @@ type User struct {
...
@@ -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
...
...
frontend/src/types/index.ts
View file @
bf3ef2d1
...
@@ -93,6 +93,7 @@ export interface User {
...
@@ -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
>
// 当前并发数(仅管理员列表接口返回)
// 当前并发数(仅管理员列表接口返回)
...
...
frontend/src/views/admin/UsersView.vue
View file @
bf3ef2d1
...
@@ -461,6 +461,12 @@
...
@@ -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[]>(() => [
...
@@ -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('')
...
@@ -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
...
...
frontend/src/views/admin/__tests__/UsersView.spec.ts
0 → 100644
View file @
bf3ef2d1
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
)
)
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment