Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/internal/handler/admin/admin_service_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ func (s *stubAdminService) GetUser(ctx context.Context, id int64) (*service.User
return &user, nil
}

func (s *stubAdminService) GetUserIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetUser(ctx, id)
}

func (s *stubAdminService) CreateUser(ctx context.Context, input *service.CreateUserInput) (*service.User, error) {
user := service.User{ID: 100, Email: input.Email, Status: service.StatusActive}
return &user, nil
Expand Down
36 changes: 26 additions & 10 deletions backend/internal/handler/admin/usage_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,24 @@ func (h *UsageHandler) Stats(c *gin.Context) {
EndTime: &endTime,
}

stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
var stats *usagestats.UsageStats
// nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
if parseBoolQueryWithDefault(c.Query("nocache"), false) {
s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", "bypass")
} else {
s, hit, err := h.getStatsCached(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
}

response.Success(c, stats)
Expand All @@ -344,23 +358,25 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
}

// Limit to 30 results
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}

// Return simplified user list (only id and email)
// Return simplified user list (only id, email and deleted flag)
type SimpleUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
}

result := make([]SimpleUser, len(users))
for i, u := range users {
result[i] = SimpleUser{
ID: u.ID,
Email: u.Email,
ID: u.ID,
Email: u.Email,
Deleted: u.DeletedAt != nil,
}
}

Expand Down
56 changes: 56 additions & 0 deletions backend/internal/handler/admin/usage_handler_search_users_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package admin

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

// 捕获 ListUsers 入参、返回一个已删用户的 admin service 桩。
type searchUsersAdminStub struct {
service.AdminService
gotFilters service.UserListFilters
}

func (s *searchUsersAdminStub) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
s.gotFilters = filters
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
return []service.User{
{ID: 1, Email: "active@test.com"},
{ID: 2, Email: "deleted@test.com", DeletedAt: &ts},
}, 2, nil
}

func TestAdminUsageSearchUsers_IncludesDeletedAndFlags(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &searchUsersAdminStub{}
handler := NewUsageHandler(nil, nil, stub, nil)
router := gin.New()
router.GET("/admin/usage/search-users", handler.SearchUsers)

req := httptest.NewRequest(http.MethodGet, "/admin/usage/search-users?q=test", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

require.Equal(t, http.StatusOK, rec.Code)
require.True(t, stub.gotFilters.IncludeDeleted, "SearchUsers 必须请求 IncludeDeleted")

var resp struct {
Data []struct {
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 2)
require.False(t, resp.Data[0].Deleted)
require.True(t, resp.Data[1].Deleted, "已删用户必须标记 deleted=true")
}
62 changes: 62 additions & 0 deletions backend/internal/handler/admin/usage_query_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package admin

import (
"context"
"time"

"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
)

// 与 dashboard 查询缓存同款:30s TTL 进程内缓存,仅服务 /admin/usage/stats 读路径。
var usageStatsCache = newSnapshotCache(30 * time.Second)

type usageStatsCacheKeyData struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
}

func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
start := ""
if filters.StartTime != nil {
start = filters.StartTime.UTC().Format(time.RFC3339)
}
end := ""
if filters.EndTime != nil {
end = filters.EndTime.UTC().Format(time.RFC3339)
}
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
})
}

// getStatsCached 命中则返回缓存,未命中则回源 usageService 并写缓存。
func (h *UsageHandler) getStatsCached(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, bool, error) {
key := usageStatsCacheKey(filters)
entry, hit, err := usageStatsCache.GetOrLoad(key, func() (any, error) {
return h.usageService.GetStatsWithFilters(ctx, filters)
})
if err != nil {
return nil, hit, err
}
stats, err := snapshotPayloadAs[*usagestats.UsageStats](entry.Payload)
return stats, hit, err
}
28 changes: 28 additions & 0 deletions backend/internal/handler/admin/usage_query_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package admin

import (
"testing"
"time"

"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/stretchr/testify/require"
)

func TestUsageStatsCacheKey_StableAndDistinct(t *testing.T) {
start := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC)
base := usagestats.UsageLogFilters{StartTime: &start, EndTime: &end, Model: "claude-3"}

k1 := usageStatsCacheKey(base)
k2 := usageStatsCacheKey(base)
require.NotEmpty(t, k1)
require.Equal(t, k1, k2, "same filters must produce same key")

other := base
other.Model = "gpt-4o"
require.NotEqual(t, k1, usageStatsCacheKey(other), "different model must change key")

withUser := base
withUser.UserID = 7
require.NotEqual(t, k1, usageStatsCacheKey(withUser), "different user must change key")
}
7 changes: 6 additions & 1 deletion backend/internal/handler/admin/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@ func (h *UserHandler) GetByID(c *gin.Context) {
return
}

user, err := h.adminService.GetUser(c.Request.Context(), userID)
var user *service.User
if c.Query("include_deleted") == "true" {
user, err = h.adminService.GetUserIncludeDeleted(c.Request.Context(), userID)
} else {
user, err = h.adminService.GetUser(c.Request.Context(), userID)
}
if err != nil {
response.ErrorFrom(c, err)
return
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/handler/admin/user_handler_get_deleted_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package admin

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

type getByIDAdminStub struct {
service.AdminService
}

func (s *getByIDAdminStub) GetUser(_ context.Context, _ int64) (*service.User, error) {
return nil, service.ErrUserNotFound
}

func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*service.User, error) {
return &service.User{ID: id, Email: "del@test.com"}, nil
}

func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewUserHandler(svc, nil, nil, nil)
r.GET("/admin/users/:id", h.GetByID)
return r
}

func TestAdminUserGetByID_IncludeDeleted(t *testing.T) {
svc := &getByIDAdminStub{AdminService: newStubAdminService()}
router := setupGetByIDRouter(svc)

t.Run("normal path returns 404 for deleted user", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
})

t.Run("include_deleted=true returns 200", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7?include_deleted=true", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
})
}
4 changes: 4 additions & 0 deletions backend/internal/handler/auth_oauth_pending_flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2914,6 +2914,10 @@ func (r *oauthPendingFlowUserRepo) DisableTotp(ctx context.Context, userID int64
Exec(ctx)
}

func (r *oauthPendingFlowUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return r.GetByID(ctx, id)
}

func oauthPendingFlowServiceUser(entity *dbent.User) *service.User {
if entity == nil {
return nil
Expand Down
1 change: 1 addition & 0 deletions backend/internal/handler/dto/mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func UserFromServiceShallow(u *service.User) *User {
BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
TotalRecharged: u.TotalRecharged,
RPMLimit: u.RPMLimit,
DeletedAt: u.DeletedAt,
}
}

Expand Down
20 changes: 20 additions & 0 deletions backend/internal/handler/dto/mappers_deleted_user_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dto

import (
"testing"
"time"

"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)

func TestUserFromServiceShallow_MapsDeletedAt(t *testing.T) {
ts := time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC)

deleted := UserFromServiceShallow(&service.User{ID: 1, Email: "d@test.com", DeletedAt: &ts})
require.NotNil(t, deleted.DeletedAt)
require.Equal(t, ts, *deleted.DeletedAt)

active := UserFromServiceShallow(&service.User{ID: 2, Email: "a@test.com"})
require.Nil(t, active.DeletedAt, "active user must have nil DeletedAt")
}
1 change: 1 addition & 0 deletions backend/internal/handler/dto/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type User struct {
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`

// 余额不足通知
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/handler/user_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context,
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
func (s *userHandlerRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetByID(ctx, id)
}
func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
out := make([]service.UserAuthIdentityRecord, len(s.identities))
copy(out, s.identities)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/repository/api_key_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ func userEntityToService(u *dbent.User) *service.User {
RPMLimit: u.RpmLimit,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DeletedAt: u.DeletedAt,
}
// Parse extra emails JSON (supports both old []string and new []NotifyEmailEntry format)
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
Expand Down
Loading
Loading