diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 02fe4aa8..925b69f2 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -219,7 +219,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) channelHandler := admin.NewChannelHandler(channelService, billingService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler) + soraGenerationRepository := repository.NewSoraGenerationRepository(db) + soraS3Storage := service.NewSoraS3Storage(settingService) + soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService) + soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService) + soraHandler := admin.NewSoraHandler(soraGenerationService, soraQuotaService, userRepository) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler, soraHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -229,10 +234,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository) soraGatewayService := service.NewSoraGatewayService(soraSDKClient, rateLimitService, httpUpstream, configConfig) soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig) - soraGenerationRepository := repository.NewSoraGenerationRepository(db) - soraS3Storage := service.NewSoraS3Storage(settingService) - soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService) - soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService) soraMediaStorage := service.ProvideSoraMediaStorage(configConfig) soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraS3Storage, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) diff --git a/backend/internal/handler/admin/sora_handler.go b/backend/internal/handler/admin/sora_handler.go new file mode 100644 index 00000000..395064d9 --- /dev/null +++ b/backend/internal/handler/admin/sora_handler.go @@ -0,0 +1,191 @@ +package admin + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// SoraHandler handles admin Sora statistics and management +type SoraHandler struct { + soraGenService *service.SoraGenerationService + soraQuotaService *service.SoraQuotaService + userRepo service.UserRepository +} + +// NewSoraHandler creates a new admin Sora handler +func NewSoraHandler( + soraGenService *service.SoraGenerationService, + soraQuotaService *service.SoraQuotaService, + userRepo service.UserRepository, +) *SoraHandler { + return &SoraHandler{ + soraGenService: soraGenService, + soraQuotaService: soraQuotaService, + userRepo: userRepo, + } +} + +// SoraSystemStatsResponse 系统级 Sora 统计 +type SoraSystemStatsResponse struct { + TotalUsers int64 `json:"total_users"` + TotalGenerations int64 `json:"total_generations"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + ActiveGenerations int64 `json:"active_generations"` + ByStatus map[string]int64 `json:"by_status"` + ByModel map[string]int64 `json:"by_model"` +} + +// GetSystemStats 获取 Sora 系统统计 +// GET /api/v1/admin/sora/stats +func (h *SoraHandler) GetSystemStats(c *gin.Context) { + ctx := c.Request.Context() + + // 获取所有用户的 Sora 统计 + users, _, err := h.userRepo.List(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}) + if err != nil { + response.Error(c, 500, "Failed to get users") + return + } + + var totalStorageBytes int64 + byStatus := make(map[string]int64) + byModel := make(map[string]int64) + + // 遍历用户统计 + for _, u := range users { + totalStorageBytes += u.SoraStorageUsedBytes + } + + resp := SoraSystemStatsResponse{ + TotalUsers: int64(len(users)), + TotalGenerations: 0, + TotalStorageBytes: totalStorageBytes, + ActiveGenerations: 0, + ByStatus: byStatus, + ByModel: byModel, + } + + response.Success(c, resp) +} + +// SoraUserStatsResponse 用户级 Sora 统计 +type SoraUserStatsResponse struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + QuotaBytes int64 `json:"quota_bytes"` + UsedBytes int64 `json:"used_bytes"` + AvailableBytes int64 `json:"available_bytes"` + QuotaSource string `json:"quota_source"` + GenerationsCount int64 `json:"generations_count"` + ActiveCount int64 `json:"active_count"` + TotalFileSizeBytes int64 `json:"total_file_size_bytes"` +} + +// ListUserStats 获取用户 Sora 使用统计列表 +// GET /api/v1/admin/sora/users +func (h *SoraHandler) ListUserStats(c *gin.Context) { + ctx := c.Request.Context() + page, pageSize := response.ParsePagination(c) + search := c.Query("search") + + filters := service.UserListFilters{ + Search: search, + } + + users, result, err := h.userRepo.ListWithFilters(ctx, pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + }, filters) + if err != nil { + response.Error(c, 500, "Failed to get users") + return + } + + results := make([]SoraUserStatsResponse, len(users)) + for i, u := range users { + quota, _ := h.soraQuotaService.GetQuota(ctx, u.ID) + activeCount, _ := h.soraGenService.CountActiveByUser(ctx, u.ID) + + quotaBytes := int64(0) + availableBytes := int64(0) + quotaSource := "unlimited" + + if quota != nil { + quotaBytes = quota.QuotaBytes + availableBytes = quota.AvailableBytes + quotaSource = quota.QuotaSource + } + + results[i] = SoraUserStatsResponse{ + UserID: u.ID, + Username: u.Username, + Email: u.Email, + QuotaBytes: quotaBytes, + UsedBytes: u.SoraStorageUsedBytes, + AvailableBytes: availableBytes, + QuotaSource: quotaSource, + GenerationsCount: 0, + ActiveCount: activeCount, + TotalFileSizeBytes: u.SoraStorageUsedBytes, + } + } + + response.Paginated(c, results, result.Total, page, pageSize) +} + +// SoraGenerationAdminResponse 管理员视角的生成记录 +type SoraGenerationAdminResponse struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Model string `json:"model"` + Prompt string `json:"prompt"` + MediaType string `json:"media_type"` + Status string `json:"status"` + StorageType string `json:"storage_type"` + MediaURL string `json:"media_url"` + FileSizeBytes int64 `json:"file_size_bytes"` + ErrorMessage string `json:"error_message"` + CreatedAt string `json:"created_at"` + CompletedAt *string `json:"completed_at"` +} + +// ListGenerations 获取 Sora 生成记录列表(管理员视角) +// GET /api/v1/admin/sora/generations +func (h *SoraHandler) ListGenerations(c *gin.Context) { + // 简化实现:返回空列表 + // 完整实现需要扩展 repository 支持 admin 级别的查询 + response.Paginated(c, []SoraGenerationAdminResponse{}, int64(0), 1, 20) +} + +// ClearUserStorage 清除用户的 Sora 存储空间 +// DELETE /api/v1/admin/sora/users/:id/storage +func (h *SoraHandler) ClearUserStorage(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid user ID") + return + } + + // 重置用户的存储使用量 + user, err := h.userRepo.GetByID(c.Request.Context(), userID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + user.SoraStorageUsedBytes = 0 + if err := h.userRepo.Update(c.Request.Context(), user); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "User Sora storage cleared"}) +} diff --git a/backend/internal/handler/admin/sora_handler_test.go b/backend/internal/handler/admin/sora_handler_test.go new file mode 100644 index 00000000..19b13c72 --- /dev/null +++ b/backend/internal/handler/admin/sora_handler_test.go @@ -0,0 +1,165 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestSoraHandler_ListGenerations(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := &SoraHandler{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/admin/sora/generations", nil) + + handler.ListGenerations(c) + + // ListGenerations 返回空列表,不需要依赖 + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "items") +} + +func TestSoraHandler_ClearUserStorage_InvalidUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := &SoraHandler{} + + // 只测试无法解析为 int64 的情况 + testCases := []struct { + name string + userID string + expected int + }{ + {"empty string", "", http.StatusBadRequest}, + {"non-numeric", "abc", http.StatusBadRequest}, + {"float", "1.5", http.StatusBadRequest}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodDelete, "/admin/sora/users/"+tc.userID+"/storage", nil) + c.Params = gin.Params{{Key: "id", Value: tc.userID}} + + handler.ClearUserStorage(c) + + assert.Equal(t, tc.expected, w.Code) + }) + } +} + +func TestSoraSystemStatsResponse_Fields(t *testing.T) { + resp := SoraSystemStatsResponse{ + TotalUsers: 10, + TotalGenerations: 100, + TotalStorageBytes: 1024 * 1024 * 1024, + ActiveGenerations: 5, + ByStatus: map[string]int64{"completed": 80, "failed": 20}, + ByModel: map[string]int64{"sora2": 50, "sora1": 50}, + } + + assert.Equal(t, int64(10), resp.TotalUsers) + assert.Equal(t, int64(100), resp.TotalGenerations) + assert.Equal(t, int64(1024*1024*1024), resp.TotalStorageBytes) + assert.Equal(t, int64(5), resp.ActiveGenerations) + assert.Equal(t, int64(80), resp.ByStatus["completed"]) + assert.Equal(t, int64(50), resp.ByModel["sora2"]) +} + +func TestSoraUserStatsResponse_Fields(t *testing.T) { + resp := SoraUserStatsResponse{ + UserID: 1, + Username: "testuser", + Email: "test@example.com", + QuotaBytes: 10 * 1024 * 1024 * 1024, + UsedBytes: 1 * 1024 * 1024 * 1024, + AvailableBytes: 9 * 1024 * 1024 * 1024, + QuotaSource: "user", + GenerationsCount: 10, + ActiveCount: 2, + TotalFileSizeBytes: 1 * 1024 * 1024 * 1024, + } + + assert.Equal(t, int64(1), resp.UserID) + assert.Equal(t, "testuser", resp.Username) + assert.Equal(t, "test@example.com", resp.Email) + assert.Equal(t, int64(10*1024*1024*1024), resp.QuotaBytes) + assert.Equal(t, int64(1*1024*1024*1024), resp.UsedBytes) + assert.Equal(t, "user", resp.QuotaSource) + assert.Equal(t, int64(10), resp.GenerationsCount) + assert.Equal(t, int64(2), resp.ActiveCount) +} + +func TestSoraGenerationAdminResponse_Fields(t *testing.T) { + completedAt := "2024-01-01T12:00:00Z" + resp := SoraGenerationAdminResponse{ + ID: 1, + UserID: 100, + Username: "testuser", + Email: "test@example.com", + Model: "sora2", + Prompt: "A beautiful sunset", + MediaType: "video", + Status: "completed", + StorageType: "s3", + MediaURL: "https://example.com/video.mp4", + FileSizeBytes: 1024 * 1024 * 10, + ErrorMessage: "", + CreatedAt: "2024-01-01T10:00:00Z", + CompletedAt: &completedAt, + } + + assert.Equal(t, int64(1), resp.ID) + assert.Equal(t, int64(100), resp.UserID) + assert.Equal(t, "testuser", resp.Username) + assert.Equal(t, "sora2", resp.Model) + assert.Equal(t, "video", resp.MediaType) + assert.Equal(t, "completed", resp.Status) + assert.Equal(t, "s3", resp.StorageType) + assert.Equal(t, int64(1024*1024*10), resp.FileSizeBytes) +} + +// TestNewSoraHandler tests the constructor +func TestNewSoraHandler(t *testing.T) { + handler := NewSoraHandler(nil, nil, nil) + assert.NotNil(t, handler) + assert.Nil(t, handler.soraGenService) + assert.Nil(t, handler.soraQuotaService) + assert.Nil(t, handler.userRepo) +} + +// Test helper: verify service.User has Sora fields +func TestUser_SoraFields(t *testing.T) { + user := &service.User{ + ID: 1, + Email: "test@example.com", + SoraStorageQuotaBytes: 10 * 1024 * 1024 * 1024, + SoraStorageUsedBytes: 1 * 1024 * 1024 * 1024, + } + + assert.Equal(t, int64(1), user.ID) + assert.Equal(t, int64(10*1024*1024*1024), user.SoraStorageQuotaBytes) + assert.Equal(t, int64(1*1024*1024*1024), user.SoraStorageUsedBytes) +} + +// Test helper: verify service.QuotaInfo fields +func TestQuotaInfo_Fields(t *testing.T) { + quota := &service.QuotaInfo{ + QuotaBytes: 10 * 1024 * 1024 * 1024, + UsedBytes: 1 * 1024 * 1024 * 1024, + AvailableBytes: 9 * 1024 * 1024 * 1024, + QuotaSource: "user", + } + + assert.Equal(t, int64(10*1024*1024*1024), quota.QuotaBytes) + assert.Equal(t, int64(1*1024*1024*1024), quota.UsedBytes) + assert.Equal(t, "user", quota.QuotaSource) +} diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 1453bd07..9a60aa9d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -57,6 +57,8 @@ type UpdateUserRequest struct { // GroupRates 用户专属分组倍率配置 // map[groupID]*rate,nil 表示删除该分组的专属倍率 GroupRates map[int64]*float64 `json:"group_rates"` + // Sora 存储配额(单位:字节,0 表示使用分组或系统默认配额) + SoraStorageQuotaBytes *int64 `json:"sora_storage_quota_bytes"` } // UpdateBalanceRequest represents balance update request @@ -215,15 +217,16 @@ func (h *UserHandler) Update(c *gin.Context) { // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ - Email: req.Email, - Password: req.Password, - Username: req.Username, - Notes: req.Notes, - Balance: req.Balance, - Concurrency: req.Concurrency, - Status: req.Status, - AllowedGroups: req.AllowedGroups, - GroupRates: req.GroupRates, + Email: req.Email, + Password: req.Password, + Username: req.Username, + Notes: req.Notes, + Balance: req.Balance, + Concurrency: req.Concurrency, + Status: req.Status, + AllowedGroups: req.AllowedGroups, + GroupRates: req.GroupRates, + SoraStorageQuotaBytes: req.SoraStorageQuotaBytes, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 478600eb..a89d9827 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -59,9 +59,11 @@ func UserFromServiceAdmin(u *service.User) *AdminUser { return nil } return &AdminUser{ - User: *base, - Notes: u.Notes, - GroupRates: u.GroupRates, + User: *base, + Notes: u.Notes, + GroupRates: u.GroupRates, + SoraStorageQuotaBytes: u.SoraStorageQuotaBytes, + SoraStorageUsedBytes: u.SoraStorageUsedBytes, } } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e026ca65..0b70f4f0 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -31,6 +31,10 @@ type AdminUser struct { // GroupRates 用户专属分组倍率配置 // map[groupID]rateMultiplier GroupRates map[int64]float64 `json:"group_rates,omitempty"` + + // Sora 存储配额 + SoraStorageQuotaBytes int64 `json:"sora_storage_quota_bytes"` + SoraStorageUsedBytes int64 `json:"sora_storage_used_bytes"` } type APIKey struct { diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index adf709b5..6579f7fe 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -32,6 +32,7 @@ type AdminHandlers struct { ScheduledTest *admin.ScheduledTestHandler Channel *admin.ChannelHandler Payment *admin.PaymentHandler + Sora *admin.SoraHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 305a4632..2358ac04 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -35,6 +35,7 @@ func ProvideAdminHandlers( scheduledTestHandler *admin.ScheduledTestHandler, channelHandler *admin.ChannelHandler, paymentHandler *admin.PaymentHandler, + soraHandler *admin.SoraHandler, ) *AdminHandlers { return &AdminHandlers{ Dashboard: dashboardHandler, @@ -63,6 +64,7 @@ func ProvideAdminHandlers( ScheduledTest: scheduledTestHandler, Channel: channelHandler, Payment: paymentHandler, + Sora: soraHandler, } } @@ -163,6 +165,7 @@ var ProviderSet = wire.NewSet( admin.NewScheduledTestHandler, admin.NewChannelHandler, admin.NewPaymentHandler, + admin.NewSoraHandler, // AdminHandlers and Handlers constructors ProvideAdminHandlers, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index fa07cfc6..d60e6573 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -88,6 +88,19 @@ func RegisterAdminRoutes( // 渠道管理 registerChannelRoutes(admin, h) + + // Sora 管理 + registerSoraRoutes(admin, h) + } +} + +func registerSoraRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + sora := admin.Group("/sora") + { + sora.GET("/stats", h.Admin.Sora.GetSystemStats) + sora.GET("/users", h.Admin.Sora.ListUserStats) + sora.GET("/generations", h.Admin.Sora.ListGenerations) + sora.DELETE("/users/:id/storage", h.Admin.Sora.ClearUserStorage) } } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 97b42c24..6363ae4b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -125,6 +125,8 @@ type UpdateUserInput struct { // GroupRates 用户专属分组倍率配置 // map[groupID]*rate,nil 表示删除该分组的专属倍率 GroupRates map[int64]*float64 + // Sora 存储配额(单位:字节,0 表示使用分组或系统默认配额) + SoraStorageQuotaBytes *int64 } type CreateGroupInput struct { @@ -628,6 +630,10 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda user.AllowedGroups = *input.AllowedGroups } + if input.SoraStorageQuotaBytes != nil { + user.SoraStorageQuotaBytes = *input.SoraStorageQuotaBytes + } + if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 72597365..9c828509 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -27,6 +27,7 @@ import backupAPI from './backup' import tlsFingerprintProfileAPI from './tlsFingerprintProfile' import channelsAPI from './channels' import adminPaymentAPI from './payment' +import soraAdminAPI from './sora' /** * Unified admin API object for convenient access @@ -55,7 +56,8 @@ export const adminAPI = { backup: backupAPI, tlsFingerprintProfiles: tlsFingerprintProfileAPI, channels: channelsAPI, - payment: adminPaymentAPI + payment: adminPaymentAPI, + sora: soraAdminAPI } export { @@ -82,7 +84,8 @@ export { backupAPI, tlsFingerprintProfileAPI, channelsAPI, - adminPaymentAPI + adminPaymentAPI, + soraAdminAPI } export default adminAPI diff --git a/frontend/src/api/admin/sora.ts b/frontend/src/api/admin/sora.ts new file mode 100644 index 00000000..ad67803a --- /dev/null +++ b/frontend/src/api/admin/sora.ts @@ -0,0 +1,95 @@ +/** + * Admin Sora API + * 管理员 Sora 统计和用户配额管理接口 + */ + +import { apiClient } from '../client' +import type { BasePaginationResponse } from '@/types' + +export interface SoraUserStats { + user_id: number + username: string + email: string + quota_bytes: number + used_bytes: number + available_bytes: number + quota_source: string + generations_count: number + active_count: number + total_file_size_bytes: number +} + +export interface SoraSystemStats { + total_users: number + total_generations: number + total_storage_bytes: number + active_generations: number + by_status: Record + by_model: Record +} + +export interface SoraGenerationAdmin { + id: number + user_id: number + username: string + email: string + model: string + prompt: string + media_type: string + status: string + storage_type: string + media_url: string + file_size_bytes: number + error_message: string + created_at: string + completed_at: string | null +} + +const soraAdminAPI = { + /** + * 获取 Sora 系统统计 + */ + async getSystemStats(): Promise { + const { data } = await apiClient.get<{ data: SoraSystemStats }>('/admin/sora/stats') + return data.data + }, + + /** + * 获取用户 Sora 使用统计列表 + */ + async listUserStats(params?: { + page?: number + page_size?: number + search?: string + sort_by?: string + sort_order?: string + }): Promise> { + const { data } = await apiClient.get>('/admin/sora/users', { params }) + return data + }, + + /** + * 获取 Sora 生成记录列表(管理员视角) + */ + async listGenerations(params?: { + page?: number + page_size?: number + user_id?: number + status?: string + model?: string + sort_by?: string + sort_order?: string + }): Promise> { + const { data } = await apiClient.get>('/admin/sora/generations', { params }) + return data + }, + + /** + * 删除用户的 Sora 存储空间(管理员操作) + */ + async clearUserStorage(userId: number): Promise { + await apiClient.delete(`/admin/sora/users/${userId}/storage`) + }, +} + +export default soraAdminAPI diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8b72d6d1..6822bbfe 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2294,6 +2294,16 @@ const syncFormFromAccount = (newAccount: Account | null) => { editApiKey.value = '' } +// loadTLSProfiles must be defined before the watch that uses it +const loadTLSProfiles = async () => { + try { + const profiles = await adminAPI.tlsFingerprintProfiles.list() + tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) + } catch { + tlsFingerprintProfiles.value = [] + } +} + watch( [() => props.show, () => props.account], ([show, newAccount], [wasShow, previousAccount]) => { @@ -2308,15 +2318,6 @@ watch( { immediate: true } ) -const loadTLSProfiles = async () => { - try { - const profiles = await adminAPI.tlsFingerprintProfiles.list() - tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) - } catch { - tlsFingerprintProfiles.value = [] - } -} - // Model mapping helpers const addModelMapping = () => { modelMappings.value.push({ from: '', to: '' }) diff --git a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts index 7cdf7999..f758e6b0 100644 --- a/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts +++ b/frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts @@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => { } }) - expect(wrapper.text()).toContain('account.creditsExhausted') + expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted') }) it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => { @@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => { expect(wrapper.text()).toContain('CSon45') expect(wrapper.text()).not.toContain('⚡') // AICredits 积分耗尽状态应显示 - expect(wrapper.text()).toContain('account.creditsExhausted') + expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted') }) }) diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 9158da64..8ad40b56 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -3,6 +3,21 @@ import { flushPromises, mount } from '@vue/test-utils' import AccountUsageCell from '../AccountUsageCell.vue' import type { Account } from '@/types' +// Mock window.matchMedia for responsive hooks +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + const { getUsage } = vi.hoisted(() => ({ getUsage: vi.fn() })) @@ -193,7 +208,7 @@ describe('AccountUsageCell', () => { await flushPromises() - expect(getUsage).toHaveBeenCalledWith(2000) + expect(getUsage).toHaveBeenCalledWith(2000, undefined) expect(wrapper.text()).toContain('5h|15|300') expect(wrapper.text()).toContain('7d|77|300') }) @@ -254,7 +269,7 @@ describe('AccountUsageCell', () => { await flushPromises() - expect(getUsage).toHaveBeenCalledWith(2001) + expect(getUsage).toHaveBeenCalledWith(2001, undefined) // 单一数据源:始终使用 /usage API 返回值,忽略 codex 快照 expect(wrapper.text()).toContain('5h|18|900') expect(wrapper.text()).toContain('7d|36|900') @@ -325,7 +340,7 @@ describe('AccountUsageCell', () => { // 手动刷新再拉一次 expect(getUsage).toHaveBeenCalledTimes(2) - expect(getUsage).toHaveBeenCalledWith(2010) + expect(getUsage).toHaveBeenCalledWith(2010, undefined) // 单一数据源:始终使用 /usage API 值 expect(wrapper.text()).toContain('5h|18|900') }) @@ -380,7 +395,7 @@ describe('AccountUsageCell', () => { await flushPromises() - expect(getUsage).toHaveBeenCalledWith(2002) + expect(getUsage).toHaveBeenCalledWith(2002, undefined) expect(wrapper.text()).toContain('5h|0|27700') expect(wrapper.text()).toContain('7d|0|27700') }) @@ -512,7 +527,7 @@ describe('AccountUsageCell', () => { await flushPromises() - expect(getUsage).toHaveBeenCalledWith(2004) + expect(getUsage).toHaveBeenCalledWith(2004, undefined) expect(wrapper.text()).toContain('5h|100|106540000') expect(wrapper.text()).toContain('7d|100|106540000') }) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index e3260168..01486558 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -34,6 +34,12 @@ vi.mock('@/api/admin/accounts', () => ({ getAntigravityDefaultModelMapping: vi.fn() })) +vi.mock('@/api/admin/tlsFingerprintProfile', () => ({ + tlsFingerprintProfileAPI: { + list: vi.fn().mockResolvedValue({ items: [] }) + } +})) + vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') return { diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 70ebd2d3..d003ff83 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -37,6 +37,11 @@ +
+ + +

{{ t('admin.users.soraStorageQuotaHint') }}

+