feat: add Sora admin backend and fix type inconsistencies
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

Backend changes:
- Add SoraHandler for admin Sora management APIs
  - GET /api/v1/admin/sora/stats - system statistics
  - GET /api/v1/admin/sora/users - user storage stats
  - GET /api/v1/admin/sora/generations - generation records
  - DELETE /api/v1/admin/sora/users/:id/storage - clear user storage
- Add sora_storage_quota_bytes to AdminUser DTO
- Add SoraStorageQuotaBytes to UpdateUserInput for admin user updates
- Add comprehensive tests for SoraHandler

Frontend changes:
- Add soraAdminAPI for Sora management
- Add sora_storage_quota_bytes and sora_storage_used_bytes to AdminUser type
- Add Sora storage quota field to UserEditModal (GB unit)
- Fix UsageLog type: add media_type, fix duration_ms to optional
- Fix AdminUsageLog type: add channel_id, billing_tier

Test fixes:
- Add window.matchMedia mock to AccountUsageCell.spec.ts
- Add tlsFingerprintProfileAPI mock to EditAccountModal.spec.ts
- Fix loadTLSProfiles function order in EditAccountModal.vue
- Fix translation key references in AccountStatusIndicator.spec.ts
This commit is contained in:
User
2026-04-16 09:20:23 +08:00
parent eb5d32553d
commit 2d59b9ebfc
18 changed files with 564 additions and 39 deletions

View File

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

View File

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

View File

@@ -57,6 +57,8 @@ type UpdateUserRequest struct {
// GroupRates 用户专属分组倍率配置
// map[groupID]*ratenil 表示删除该分组的专属倍率
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)