diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index f6780dee..926624d2 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -94,9 +94,9 @@ func (h *GroupHandler) List(c *gin.Context) { return } - outGroups := make([]dto.Group, 0, len(groups)) + outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { - outGroups = append(outGroups, *dto.GroupFromService(&groups[i])) + outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) } response.Paginated(c, outGroups, total, page, pageSize) } @@ -120,9 +120,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) { return } - outGroups := make([]dto.Group, 0, len(groups)) + outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { - outGroups = append(outGroups, *dto.GroupFromService(&groups[i])) + outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) } response.Success(c, outGroups) } @@ -142,7 +142,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.GroupFromService(group)) + response.Success(c, dto.GroupFromServiceAdmin(group)) } // Create handles creating a new group @@ -177,7 +177,7 @@ func (h *GroupHandler) Create(c *gin.Context) { return } - response.Success(c, dto.GroupFromService(group)) + response.Success(c, dto.GroupFromServiceAdmin(group)) } // Update handles updating a group @@ -219,7 +219,7 @@ func (h *GroupHandler) Update(c *gin.Context) { return } - response.Success(c, dto.GroupFromService(group)) + response.Success(c, dto.GroupFromServiceAdmin(group)) } // Delete handles deleting a group diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 5b3229b6..f1b68334 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -54,9 +54,9 @@ func (h *RedeemHandler) List(c *gin.Context) { return } - out := make([]dto.RedeemCode, 0, len(codes)) + out := make([]dto.AdminRedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i])) } response.Paginated(c, out, total, page, pageSize) } @@ -76,7 +76,7 @@ func (h *RedeemHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.RedeemCodeFromService(code)) + response.Success(c, dto.RedeemCodeFromServiceAdmin(code)) } // Generate handles generating new redeem codes @@ -100,9 +100,9 @@ func (h *RedeemHandler) Generate(c *gin.Context) { return } - out := make([]dto.RedeemCode, 0, len(codes)) + out := make([]dto.AdminRedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i])) } response.Success(c, out) } @@ -163,7 +163,7 @@ func (h *RedeemHandler) Expire(c *gin.Context) { return } - response.Success(c, dto.RedeemCodeFromService(code)) + response.Success(c, dto.RedeemCodeFromServiceAdmin(code)) } // GetStats handles getting redeem code statistics diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 08db999a..3cdc8368 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -83,9 +83,9 @@ func (h *SubscriptionHandler) List(c *gin.Context) { return } - out := make([]dto.UserSubscription, 0, len(subscriptions)) + out := make([]dto.AdminUserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } response.PaginatedWithResult(c, out, toResponsePagination(pagination)) } @@ -105,7 +105,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromService(subscription)) + response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) } // GetProgress handles getting subscription usage progress @@ -150,7 +150,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromService(subscription)) + response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) } // BulkAssign handles bulk assigning subscriptions to multiple users @@ -201,7 +201,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromService(subscription)) + response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) } // Revoke handles revoking a subscription @@ -239,9 +239,9 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) { return } - out := make([]dto.UserSubscription, 0, len(subscriptions)) + out := make([]dto.AdminUserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } response.PaginatedWithResult(c, out, toResponsePagination(pagination)) } @@ -261,9 +261,9 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) { return } - out := make([]dto.UserSubscription, 0, len(subscriptions)) + out := make([]dto.AdminUserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } response.Success(c, out) } diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 81aa78e1..3f3238dd 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -163,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) { return } - out := make([]dto.UsageLog, 0, len(records)) + out := make([]dto.AdminUsageLog, 0, len(records)) for i := range records { out = append(out, *dto.UsageLogFromServiceAdmin(&records[i])) } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 38cc8acd..9a5a691f 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -84,9 +84,9 @@ func (h *UserHandler) List(c *gin.Context) { return } - out := make([]dto.User, 0, len(users)) + out := make([]dto.AdminUser, 0, len(users)) for i := range users { - out = append(out, *dto.UserFromService(&users[i])) + out = append(out, *dto.UserFromServiceAdmin(&users[i])) } response.Paginated(c, out, total, page, pageSize) } @@ -129,7 +129,7 @@ func (h *UserHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UserFromService(user)) + response.Success(c, dto.UserFromServiceAdmin(user)) } // Create handles creating a new user @@ -155,7 +155,7 @@ func (h *UserHandler) Create(c *gin.Context) { return } - response.Success(c, dto.UserFromService(user)) + response.Success(c, dto.UserFromServiceAdmin(user)) } // Update handles updating a user @@ -189,7 +189,7 @@ func (h *UserHandler) Update(c *gin.Context) { return } - response.Success(c, dto.UserFromService(user)) + response.Success(c, dto.UserFromServiceAdmin(user)) } // Delete handles deleting a user @@ -231,7 +231,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) { return } - response.Success(c, dto.UserFromService(user)) + response.Success(c, dto.UserFromServiceAdmin(user)) } // GetUserAPIKeys handles getting user's API keys diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 66b86ea0..d58a8a29 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -15,7 +15,6 @@ func UserFromServiceShallow(u *service.User) *User { ID: u.ID, Email: u.Email, Username: u.Username, - Notes: u.Notes, Role: u.Role, Balance: u.Balance, Concurrency: u.Concurrency, @@ -48,6 +47,22 @@ func UserFromService(u *service.User) *User { return out } +// UserFromServiceAdmin converts a service User to DTO for admin users. +// It includes notes - user-facing endpoints must not use this. +func UserFromServiceAdmin(u *service.User) *AdminUser { + if u == nil { + return nil + } + base := UserFromService(u) + if base == nil { + return nil + } + return &AdminUser{ + User: *base, + Notes: u.Notes, + } +} + func APIKeyFromService(k *service.APIKey) *APIKey { if k == nil { return nil @@ -72,36 +87,29 @@ func GroupFromServiceShallow(g *service.Group) *Group { if g == nil { return nil } - return &Group{ - ID: g.ID, - Name: g.Name, - Description: g.Description, - Platform: g.Platform, - RateMultiplier: g.RateMultiplier, - IsExclusive: g.IsExclusive, - Status: g.Status, - SubscriptionType: g.SubscriptionType, - DailyLimitUSD: g.DailyLimitUSD, - WeeklyLimitUSD: g.WeeklyLimitUSD, - MonthlyLimitUSD: g.MonthlyLimitUSD, - ImagePrice1K: g.ImagePrice1K, - ImagePrice2K: g.ImagePrice2K, - ImagePrice4K: g.ImagePrice4K, - ClaudeCodeOnly: g.ClaudeCodeOnly, - FallbackGroupID: g.FallbackGroupID, - ModelRouting: g.ModelRouting, - ModelRoutingEnabled: g.ModelRoutingEnabled, - CreatedAt: g.CreatedAt, - UpdatedAt: g.UpdatedAt, - AccountCount: g.AccountCount, - } + out := groupFromServiceBase(g) + return &out } func GroupFromService(g *service.Group) *Group { if g == nil { return nil } - out := GroupFromServiceShallow(g) + return GroupFromServiceShallow(g) +} + +// GroupFromServiceAdmin converts a service Group to DTO for admin users. +// It includes internal fields like model_routing and account_count. +func GroupFromServiceAdmin(g *service.Group) *AdminGroup { + if g == nil { + return nil + } + out := &AdminGroup{ + Group: groupFromServiceBase(g), + ModelRouting: g.ModelRouting, + ModelRoutingEnabled: g.ModelRoutingEnabled, + AccountCount: g.AccountCount, + } if len(g.AccountGroups) > 0 { out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups)) for i := range g.AccountGroups { @@ -112,6 +120,29 @@ func GroupFromService(g *service.Group) *Group { return out } +func groupFromServiceBase(g *service.Group) Group { + return Group{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Platform: g.Platform, + RateMultiplier: g.RateMultiplier, + IsExclusive: g.IsExclusive, + Status: g.Status, + SubscriptionType: g.SubscriptionType, + DailyLimitUSD: g.DailyLimitUSD, + WeeklyLimitUSD: g.WeeklyLimitUSD, + MonthlyLimitUSD: g.MonthlyLimitUSD, + ImagePrice1K: g.ImagePrice1K, + ImagePrice2K: g.ImagePrice2K, + ImagePrice4K: g.ImagePrice4K, + ClaudeCodeOnly: g.ClaudeCodeOnly, + FallbackGroupID: g.FallbackGroupID, + CreatedAt: g.CreatedAt, + UpdatedAt: g.UpdatedAt, + } +} + func AccountFromServiceShallow(a *service.Account) *Account { if a == nil { return nil @@ -273,7 +304,24 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode { if rc == nil { return nil } - return &RedeemCode{ + out := redeemCodeFromServiceBase(rc) + return &out +} + +// RedeemCodeFromServiceAdmin converts a service RedeemCode to DTO for admin users. +// It includes notes - user-facing endpoints must not use this. +func RedeemCodeFromServiceAdmin(rc *service.RedeemCode) *AdminRedeemCode { + if rc == nil { + return nil + } + return &AdminRedeemCode{ + RedeemCode: redeemCodeFromServiceBase(rc), + Notes: rc.Notes, + } +} + +func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode { + return RedeemCode{ ID: rc.ID, Code: rc.Code, Type: rc.Type, @@ -281,7 +329,6 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode { Status: rc.Status, UsedBy: rc.UsedBy, UsedAt: rc.UsedAt, - Notes: rc.Notes, CreatedAt: rc.CreatedAt, GroupID: rc.GroupID, ValidityDays: rc.ValidityDays, @@ -302,14 +349,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary { } } -// usageLogFromServiceBase is a helper that converts service UsageLog to DTO. -// The account parameter allows caller to control what Account info is included. -// The includeIPAddress parameter controls whether to include the IP address (admin-only). -func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, includeIPAddress bool) *UsageLog { - if l == nil { - return nil - } - result := &UsageLog{ +func usageLogFromServiceUser(l *service.UsageLog) UsageLog { + // 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。 + return UsageLog{ ID: l.ID, UserID: l.UserID, APIKeyID: l.APIKeyID, @@ -331,7 +373,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu TotalCost: l.TotalCost, ActualCost: l.ActualCost, RateMultiplier: l.RateMultiplier, - AccountRateMultiplier: l.AccountRateMultiplier, BillingType: l.BillingType, Stream: l.Stream, DurationMs: l.DurationMs, @@ -342,30 +383,33 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu CreatedAt: l.CreatedAt, User: UserFromServiceShallow(l.User), APIKey: APIKeyFromService(l.APIKey), - Account: account, Group: GroupFromServiceShallow(l.Group), Subscription: UserSubscriptionFromService(l.Subscription), } - // IP 地址仅对管理员可见 - if includeIPAddress { - result.IPAddress = l.IPAddress - } - return result } // UsageLogFromService converts a service UsageLog to DTO for regular users. // It excludes Account details and IP address - users should not see these. func UsageLogFromService(l *service.UsageLog) *UsageLog { - return usageLogFromServiceBase(l, nil, false) + if l == nil { + return nil + } + u := usageLogFromServiceUser(l) + return &u } // UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users. // It includes minimal Account info (ID, Name only) and IP address. -func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog { +func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { if l == nil { return nil } - return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account), true) + return &AdminUsageLog{ + UsageLog: usageLogFromServiceUser(l), + AccountRateMultiplier: l.AccountRateMultiplier, + IPAddress: l.IPAddress, + Account: AccountSummaryFromService(l.Account), + } } func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask { @@ -414,7 +458,27 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio if sub == nil { return nil } - return &UserSubscription{ + out := userSubscriptionFromServiceBase(sub) + return &out +} + +// UserSubscriptionFromServiceAdmin converts a service UserSubscription to DTO for admin users. +// It includes assignment metadata and notes. +func UserSubscriptionFromServiceAdmin(sub *service.UserSubscription) *AdminUserSubscription { + if sub == nil { + return nil + } + return &AdminUserSubscription{ + UserSubscription: userSubscriptionFromServiceBase(sub), + AssignedBy: sub.AssignedBy, + AssignedAt: sub.AssignedAt, + Notes: sub.Notes, + AssignedByUser: UserFromServiceShallow(sub.AssignedByUser), + } +} + +func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscription { + return UserSubscription{ ID: sub.ID, UserID: sub.UserID, GroupID: sub.GroupID, @@ -427,14 +491,10 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio DailyUsageUSD: sub.DailyUsageUSD, WeeklyUsageUSD: sub.WeeklyUsageUSD, MonthlyUsageUSD: sub.MonthlyUsageUSD, - AssignedBy: sub.AssignedBy, - AssignedAt: sub.AssignedAt, - Notes: sub.Notes, CreatedAt: sub.CreatedAt, UpdatedAt: sub.UpdatedAt, User: UserFromServiceShallow(sub.User), Group: GroupFromServiceShallow(sub.Group), - AssignedByUser: UserFromServiceShallow(sub.AssignedByUser), } } @@ -442,9 +502,9 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult if r == nil { return nil } - subs := make([]UserSubscription, 0, len(r.Subscriptions)) + subs := make([]AdminUserSubscription, 0, len(r.Subscriptions)) for i := range r.Subscriptions { - subs = append(subs, *UserSubscriptionFromService(&r.Subscriptions[i])) + subs = append(subs, *UserSubscriptionFromServiceAdmin(&r.Subscriptions[i])) } return &BulkAssignResult{ SuccessCount: r.SuccessCount, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 4247dcbf..938d707c 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -6,7 +6,6 @@ type User struct { ID int64 `json:"id"` Email string `json:"email"` Username string `json:"username"` - Notes string `json:"notes"` Role string `json:"role"` Balance float64 `json:"balance"` Concurrency int `json:"concurrency"` @@ -19,6 +18,14 @@ type User struct { Subscriptions []UserSubscription `json:"subscriptions,omitempty"` } +// AdminUser 是管理员接口使用的 user DTO(包含敏感/内部字段)。 +// 注意:普通用户接口不得返回 notes 等管理员备注信息。 +type AdminUser struct { + User + + Notes string `json:"notes"` +} + type APIKey struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -58,13 +65,19 @@ type Group struct { ClaudeCodeOnly bool `json:"claude_code_only"` FallbackGroupID *int64 `json:"fallback_group_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AdminGroup 是管理员接口使用的 group DTO(包含敏感/内部字段)。 +// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。 +type AdminGroup struct { + Group + // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled bool `json:"model_routing_enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - AccountGroups []AccountGroup `json:"account_groups,omitempty"` AccountCount int64 `json:"account_count,omitempty"` } @@ -180,7 +193,6 @@ type RedeemCode struct { Status string `json:"status"` UsedBy *int64 `json:"used_by"` UsedAt *time.Time `json:"used_at"` - Notes string `json:"notes"` CreatedAt time.Time `json:"created_at"` GroupID *int64 `json:"group_id"` @@ -190,6 +202,15 @@ type RedeemCode struct { Group *Group `json:"group,omitempty"` } +// AdminRedeemCode 是管理员接口使用的 redeem code DTO(包含 notes 等字段)。 +// 注意:普通用户接口不得返回 notes 等内部信息。 +type AdminRedeemCode struct { + RedeemCode + + Notes string `json:"notes"` +} + +// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。 type UsageLog struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` @@ -209,14 +230,13 @@ type UsageLog struct { CacheCreation5mTokens int `json:"cache_creation_5m_tokens"` CacheCreation1hTokens int `json:"cache_creation_1h_tokens"` - InputCost float64 `json:"input_cost"` - OutputCost float64 `json:"output_cost"` - CacheCreationCost float64 `json:"cache_creation_cost"` - CacheReadCost float64 `json:"cache_read_cost"` - TotalCost float64 `json:"total_cost"` - ActualCost float64 `json:"actual_cost"` - RateMultiplier float64 `json:"rate_multiplier"` - AccountRateMultiplier *float64 `json:"account_rate_multiplier"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + CacheCreationCost float64 `json:"cache_creation_cost"` + CacheReadCost float64 `json:"cache_read_cost"` + TotalCost float64 `json:"total_cost"` + ActualCost float64 `json:"actual_cost"` + RateMultiplier float64 `json:"rate_multiplier"` BillingType int8 `json:"billing_type"` Stream bool `json:"stream"` @@ -230,18 +250,28 @@ type UsageLog struct { // User-Agent UserAgent *string `json:"user_agent"` - // IP 地址(仅管理员可见) - IPAddress *string `json:"ip_address,omitempty"` - CreatedAt time.Time `json:"created_at"` User *User `json:"user,omitempty"` APIKey *APIKey `json:"api_key,omitempty"` - Account *AccountSummary `json:"account,omitempty"` // Use minimal AccountSummary to prevent data leakage Group *Group `json:"group,omitempty"` Subscription *UserSubscription `json:"subscription,omitempty"` } +// AdminUsageLog 是管理员接口使用的 usage log DTO(包含管理员字段)。 +type AdminUsageLog struct { + UsageLog + + // AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理) + AccountRateMultiplier *float64 `json:"account_rate_multiplier"` + + // IPAddress 用户请求 IP(仅管理员可见) + IPAddress *string `json:"ip_address,omitempty"` + + // Account 最小账号信息(避免泄露敏感字段) + Account *AccountSummary `json:"account,omitempty"` +} + type UsageCleanupFilters struct { StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` @@ -300,23 +330,30 @@ type UserSubscription struct { WeeklyUsageUSD float64 `json:"weekly_usage_usd"` MonthlyUsageUSD float64 `json:"monthly_usage_usd"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + User *User `json:"user,omitempty"` + Group *Group `json:"group,omitempty"` +} + +// AdminUserSubscription 是管理员接口使用的订阅 DTO(包含分配信息/备注等字段)。 +// 注意:普通用户接口不得返回 assigned_by/assigned_at/notes/assigned_by_user 等管理员字段。 +type AdminUserSubscription struct { + UserSubscription + AssignedBy *int64 `json:"assigned_by"` AssignedAt time.Time `json:"assigned_at"` Notes string `json:"notes"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - User *User `json:"user,omitempty"` - Group *Group `json:"group,omitempty"` - AssignedByUser *User `json:"assigned_by_user,omitempty"` + AssignedByUser *User `json:"assigned_by_user,omitempty"` } type BulkAssignResult struct { - SuccessCount int `json:"success_count"` - FailedCount int `json:"failed_count"` - Subscriptions []UserSubscription `json:"subscriptions"` - Errors []string `json:"errors"` + SuccessCount int `json:"success_count"` + FailedCount int `json:"failed_count"` + Subscriptions []AdminUserSubscription `json:"subscriptions"` + Errors []string `json:"errors"` } // PromoCode 注册优惠码 diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index d968951c..35862f1c 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -47,9 +47,6 @@ func (h *UserHandler) GetProfile(c *gin.Context) { return } - // 清空notes字段,普通用户不应看到备注 - userData.Notes = "" - response.Success(c, dto.UserFromService(userData)) } @@ -105,8 +102,5 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { return } - // 清空notes字段,普通用户不应看到备注 - updatedUser.Notes = "" - response.Success(c, dto.UserFromService(updatedUser)) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 666ae52c..4ce58942 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -51,7 +51,6 @@ func TestAPIContracts(t *testing.T) { "id": 1, "email": "alice@example.com", "username": "alice", - "notes": "hello", "role": "user", "balance": 12.5, "concurrency": 5, @@ -131,6 +130,153 @@ func TestAPIContracts(t *testing.T) { } }`, }, + { + name: "GET /api/v1/groups/available", + setup: func(t *testing.T, deps *contractDeps) { + t.Helper() + // 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count)。 + deps.groupRepo.SetActive([]service.Group{ + { + ID: 10, + Name: "Group One", + Description: "desc", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.5, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{ + "claude-3-*": []int64{101, 102}, + }, + AccountCount: 2, + CreatedAt: deps.now, + UpdatedAt: deps.now, + }, + }) + deps.userSubRepo.SetActiveByUserID(1, nil) + }, + method: http.MethodGet, + path: "/api/v1/groups/available", + wantStatus: http.StatusOK, + wantJSON: `{ + "code": 0, + "message": "success", + "data": [ + { + "id": 10, + "name": "Group One", + "description": "desc", + "platform": "anthropic", + "rate_multiplier": 1.5, + "is_exclusive": false, + "status": "active", + "subscription_type": "standard", + "daily_limit_usd": null, + "weekly_limit_usd": null, + "monthly_limit_usd": null, + "image_price_1k": null, + "image_price_2k": null, + "image_price_4k": null, + "claude_code_only": false, + "fallback_group_id": null, + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-01-02T03:04:05Z" + } + ] + }`, + }, + { + name: "GET /api/v1/subscriptions", + setup: func(t *testing.T, deps *contractDeps) { + t.Helper() + // 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。 + deps.userSubRepo.SetByUserID(1, []service.UserSubscription{ + { + ID: 501, + UserID: 1, + GroupID: 10, + StartsAt: deps.now, + ExpiresAt: deps.now.Add(24 * time.Hour), + Status: service.SubscriptionStatusActive, + DailyUsageUSD: 1.23, + WeeklyUsageUSD: 2.34, + MonthlyUsageUSD: 3.45, + AssignedBy: ptr(int64(999)), + AssignedAt: deps.now, + Notes: "admin-note", + CreatedAt: deps.now, + UpdatedAt: deps.now, + }, + }) + }, + method: http.MethodGet, + path: "/api/v1/subscriptions", + wantStatus: http.StatusOK, + wantJSON: `{ + "code": 0, + "message": "success", + "data": [ + { + "id": 501, + "user_id": 1, + "group_id": 10, + "starts_at": "2025-01-02T03:04:05Z", + "expires_at": "2025-01-03T03:04:05Z", + "status": "active", + "daily_window_start": null, + "weekly_window_start": null, + "monthly_window_start": null, + "daily_usage_usd": 1.23, + "weekly_usage_usd": 2.34, + "monthly_usage_usd": 3.45, + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-01-02T03:04:05Z" + } + ] + }`, + }, + { + name: "GET /api/v1/redeem/history", + setup: func(t *testing.T, deps *contractDeps) { + t.Helper() + // 普通用户兑换历史不应包含 notes 等内部字段。 + deps.redeemRepo.SetByUser(1, []service.RedeemCode{ + { + ID: 900, + Code: "CODE-123", + Type: service.RedeemTypeBalance, + Value: 1.25, + Status: service.StatusUsed, + UsedBy: ptr(int64(1)), + UsedAt: ptr(deps.now), + Notes: "internal-note", + CreatedAt: deps.now, + }, + }) + }, + method: http.MethodGet, + path: "/api/v1/redeem/history", + wantStatus: http.StatusOK, + wantJSON: `{ + "code": 0, + "message": "success", + "data": [ + { + "id": 900, + "code": "CODE-123", + "type": "balance", + "value": 1.25, + "status": "used", + "used_by": 1, + "used_at": "2025-01-02T03:04:05Z", + "created_at": "2025-01-02T03:04:05Z", + "group_id": null, + "validity_days": 0 + } + ] + }`, + }, { name: "GET /api/v1/usage/stats", setup: func(t *testing.T, deps *contractDeps) { @@ -190,24 +336,25 @@ func TestAPIContracts(t *testing.T) { t.Helper() deps.usageRepo.SetUserLogs(1, []service.UsageLog{ { - ID: 1, - UserID: 1, - APIKeyID: 100, - AccountID: 200, - RequestID: "req_123", - Model: "claude-3", - InputTokens: 10, - OutputTokens: 20, - CacheCreationTokens: 1, - CacheReadTokens: 2, - TotalCost: 0.5, - ActualCost: 0.5, - RateMultiplier: 1, - BillingType: service.BillingTypeBalance, - Stream: true, - DurationMs: ptr(100), - FirstTokenMs: ptr(50), - CreatedAt: deps.now, + ID: 1, + UserID: 1, + APIKeyID: 100, + AccountID: 200, + AccountRateMultiplier: ptr(0.5), + RequestID: "req_123", + Model: "claude-3", + InputTokens: 10, + OutputTokens: 20, + CacheCreationTokens: 1, + CacheReadTokens: 2, + TotalCost: 0.5, + ActualCost: 0.5, + RateMultiplier: 1, + BillingType: service.BillingTypeBalance, + Stream: true, + DurationMs: ptr(100), + FirstTokenMs: ptr(50), + CreatedAt: deps.now, }, }) }, @@ -238,10 +385,9 @@ func TestAPIContracts(t *testing.T) { "output_cost": 0, "cache_creation_cost": 0, "cache_read_cost": 0, - "total_cost": 0.5, + "total_cost": 0.5, "actual_cost": 0.5, "rate_multiplier": 1, - "account_rate_multiplier": null, "billing_type": 0, "stream": true, "duration_ms": 100, @@ -386,8 +532,11 @@ type contractDeps struct { now time.Time router http.Handler apiKeyRepo *stubApiKeyRepo + groupRepo *stubGroupRepo + userSubRepo *stubUserSubscriptionRepo usageRepo *stubUsageLogRepo settingRepo *stubSettingRepo + redeemRepo *stubRedeemCodeRepo } func newContractDeps(t *testing.T) *contractDeps { @@ -415,11 +564,11 @@ func newContractDeps(t *testing.T) *contractDeps { apiKeyRepo := newStubApiKeyRepo(now) apiKeyCache := stubApiKeyCache{} - groupRepo := stubGroupRepo{} - userSubRepo := stubUserSubscriptionRepo{} + groupRepo := &stubGroupRepo{} + userSubRepo := &stubUserSubscriptionRepo{} accountRepo := stubAccountRepo{} proxyRepo := stubProxyRepo{} - redeemRepo := stubRedeemCodeRepo{} + redeemRepo := &stubRedeemCodeRepo{} cfg := &config.Config{ Default: config.DefaultConfig{ @@ -434,6 +583,12 @@ func newContractDeps(t *testing.T) *contractDeps { usageRepo := newStubUsageLogRepo() usageService := service.NewUsageService(usageRepo, userRepo, nil, nil) + subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil) + subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) + + redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil) + redeemHandler := handler.NewRedeemHandler(redeemService) + settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) @@ -473,12 +628,21 @@ func newContractDeps(t *testing.T) *contractDeps { v1Keys.Use(jwtAuth) v1Keys.GET("/keys", apiKeyHandler.List) v1Keys.POST("/keys", apiKeyHandler.Create) + v1Keys.GET("/groups/available", apiKeyHandler.GetAvailableGroups) v1Usage := v1.Group("") v1Usage.Use(jwtAuth) v1Usage.GET("/usage", usageHandler.List) v1Usage.GET("/usage/stats", usageHandler.Stats) + v1Subs := v1.Group("") + v1Subs.Use(jwtAuth) + v1Subs.GET("/subscriptions", subscriptionHandler.List) + + v1Redeem := v1.Group("") + v1Redeem.Use(jwtAuth) + v1Redeem.GET("/redeem/history", redeemHandler.GetHistory) + v1Admin := v1.Group("/admin") v1Admin.Use(adminAuth) v1Admin.GET("/settings", adminSettingHandler.GetSettings) @@ -488,8 +652,11 @@ func newContractDeps(t *testing.T) *contractDeps { now: now, router: r, apiKeyRepo: apiKeyRepo, + groupRepo: groupRepo, + userSubRepo: userSubRepo, usageRepo: usageRepo, settingRepo: settingRepo, + redeemRepo: redeemRepo, } } @@ -627,7 +794,13 @@ func (stubApiKeyCache) SubscribeAuthCacheInvalidation(ctx context.Context, handl return nil } -type stubGroupRepo struct{} +type stubGroupRepo struct { + active []service.Group +} + +func (r *stubGroupRepo) SetActive(groups []service.Group) { + r.active = append([]service.Group(nil), groups...) +} func (stubGroupRepo) Create(ctx context.Context, group *service.Group) error { return errors.New("not implemented") @@ -661,12 +834,19 @@ func (stubGroupRepo) ListWithFilters(ctx context.Context, params pagination.Pagi return nil, nil, errors.New("not implemented") } -func (stubGroupRepo) ListActive(ctx context.Context) ([]service.Group, error) { - return nil, errors.New("not implemented") +func (r *stubGroupRepo) ListActive(ctx context.Context) ([]service.Group, error) { + return append([]service.Group(nil), r.active...), nil } -func (stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform string) ([]service.Group, error) { - return nil, errors.New("not implemented") +func (r *stubGroupRepo) ListActiveByPlatform(ctx context.Context, platform string) ([]service.Group, error) { + out := make([]service.Group, 0, len(r.active)) + for i := range r.active { + g := r.active[i] + if g.Platform == platform { + out = append(out, g) + } + } + return out, nil } func (stubGroupRepo) ExistsByName(ctx context.Context, name string) (bool, error) { @@ -884,7 +1064,16 @@ func (stubProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID return nil, errors.New("not implemented") } -type stubRedeemCodeRepo struct{} +type stubRedeemCodeRepo struct { + byUser map[int64][]service.RedeemCode +} + +func (r *stubRedeemCodeRepo) SetByUser(userID int64, codes []service.RedeemCode) { + if r.byUser == nil { + r.byUser = make(map[int64][]service.RedeemCode) + } + r.byUser[userID] = append([]service.RedeemCode(nil), codes...) +} func (stubRedeemCodeRepo) Create(ctx context.Context, code *service.RedeemCode) error { return errors.New("not implemented") @@ -922,11 +1111,35 @@ func (stubRedeemCodeRepo) ListWithFilters(ctx context.Context, params pagination return nil, nil, errors.New("not implemented") } -func (stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit int) ([]service.RedeemCode, error) { - return nil, errors.New("not implemented") +func (r *stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit int) ([]service.RedeemCode, error) { + if r.byUser == nil { + return nil, nil + } + codes := r.byUser[userID] + if limit > 0 && len(codes) > limit { + codes = codes[:limit] + } + return append([]service.RedeemCode(nil), codes...), nil } -type stubUserSubscriptionRepo struct{} +type stubUserSubscriptionRepo struct { + byUser map[int64][]service.UserSubscription + activeByUser map[int64][]service.UserSubscription +} + +func (r *stubUserSubscriptionRepo) SetByUserID(userID int64, subs []service.UserSubscription) { + if r.byUser == nil { + r.byUser = make(map[int64][]service.UserSubscription) + } + r.byUser[userID] = append([]service.UserSubscription(nil), subs...) +} + +func (r *stubUserSubscriptionRepo) SetActiveByUserID(userID int64, subs []service.UserSubscription) { + if r.activeByUser == nil { + r.activeByUser = make(map[int64][]service.UserSubscription) + } + r.activeByUser[userID] = append([]service.UserSubscription(nil), subs...) +} func (stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { return errors.New("not implemented") @@ -946,11 +1159,17 @@ func (stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSub func (stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } -func (stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { - return nil, errors.New("not implemented") +func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { + if r.byUser == nil { + return nil, nil + } + return append([]service.UserSubscription(nil), r.byUser[userID]...), nil } -func (stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { - return nil, errors.New("not implemented") +func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { + if r.activeByUser == nil { + return nil, nil + } + return append([]service.UserSubscription(nil), r.activeByUser[userID]...), nil } func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 44eebc99..4d2b10ef 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -5,7 +5,7 @@ import { apiClient } from '../client' import type { - Group, + AdminGroup, GroupPlatform, CreateGroupRequest, UpdateGroupRequest, @@ -31,8 +31,8 @@ export async function list( options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/groups', { +): Promise> { + const { data } = await apiClient.get>('/admin/groups', { params: { page, page_size: pageSize, @@ -48,8 +48,8 @@ export async function list( * @param platform - Optional platform filter * @returns List of all active groups */ -export async function getAll(platform?: GroupPlatform): Promise { - const { data } = await apiClient.get('/admin/groups/all', { +export async function getAll(platform?: GroupPlatform): Promise { + const { data } = await apiClient.get('/admin/groups/all', { params: platform ? { platform } : undefined }) return data @@ -60,7 +60,7 @@ export async function getAll(platform?: GroupPlatform): Promise { * @param platform - Platform to filter by * @returns List of groups for the specified platform */ -export async function getByPlatform(platform: GroupPlatform): Promise { +export async function getByPlatform(platform: GroupPlatform): Promise { return getAll(platform) } @@ -69,8 +69,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise { * @param id - Group ID * @returns Group details */ -export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/groups/${id}`) +export async function getById(id: number): Promise { + const { data } = await apiClient.get(`/admin/groups/${id}`) return data } @@ -79,8 +79,8 @@ export async function getById(id: number): Promise { * @param groupData - Group data * @returns Created group */ -export async function create(groupData: CreateGroupRequest): Promise { - const { data } = await apiClient.post('/admin/groups', groupData) +export async function create(groupData: CreateGroupRequest): Promise { + const { data } = await apiClient.post('/admin/groups', groupData) return data } @@ -90,8 +90,8 @@ export async function create(groupData: CreateGroupRequest): Promise { * @param updates - Fields to update * @returns Updated group */ -export async function update(id: number, updates: UpdateGroupRequest): Promise { - const { data } = await apiClient.put(`/admin/groups/${id}`, updates) +export async function update(id: number, updates: UpdateGroupRequest): Promise { + const { data } = await apiClient.put(`/admin/groups/${id}`, updates) return data } @@ -111,7 +111,7 @@ export async function deleteGroup(id: number): Promise<{ message: string }> { * @param status - New status * @returns Updated group */ -export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { +export async function toggleStatus(id: number, status: 'active' | 'inactive'): Promise { return update(id, { status }) } diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index c271a2d0..94f7b57b 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -4,7 +4,7 @@ */ import { apiClient } from '../client' -import type { UsageLog, UsageQueryParams, PaginatedResponse } from '@/types' +import type { AdminUsageLog, UsageQueryParams, PaginatedResponse } from '@/types' // ==================== Types ==================== @@ -85,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams { export async function list( params: AdminUsageQueryParams, options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/usage', { +): Promise> { + const { data } = await apiClient.get>('/admin/usage', { params, signal: options?.signal }) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 44963cf9..734e3ac7 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -4,7 +4,7 @@ */ import { apiClient } from '../client' -import type { User, UpdateUserRequest, PaginatedResponse } from '@/types' +import type { AdminUser, UpdateUserRequest, PaginatedResponse } from '@/types' /** * List all users with pagination @@ -26,7 +26,7 @@ export async function list( options?: { signal?: AbortSignal } -): Promise> { +): Promise> { // Build params with attribute filters in attr[id]=value format const params: Record = { page, @@ -44,8 +44,7 @@ export async function list( } } } - - const { data } = await apiClient.get>('/admin/users', { + const { data } = await apiClient.get>('/admin/users', { params, signal: options?.signal }) @@ -57,8 +56,8 @@ export async function list( * @param id - User ID * @returns User details */ -export async function getById(id: number): Promise { - const { data } = await apiClient.get(`/admin/users/${id}`) +export async function getById(id: number): Promise { + const { data } = await apiClient.get(`/admin/users/${id}`) return data } @@ -73,8 +72,8 @@ export async function create(userData: { balance?: number concurrency?: number allowed_groups?: number[] | null -}): Promise { - const { data } = await apiClient.post('/admin/users', userData) +}): Promise { + const { data } = await apiClient.post('/admin/users', userData) return data } @@ -84,8 +83,8 @@ export async function create(userData: { * @param updates - Fields to update * @returns Updated user */ -export async function update(id: number, updates: UpdateUserRequest): Promise { - const { data } = await apiClient.put(`/admin/users/${id}`, updates) +export async function update(id: number, updates: UpdateUserRequest): Promise { + const { data } = await apiClient.put(`/admin/users/${id}`, updates) return data } @@ -112,8 +111,8 @@ export async function updateBalance( balance: number, operation: 'set' | 'add' | 'subtract' = 'set', notes?: string -): Promise { - const { data } = await apiClient.post(`/admin/users/${id}/balance`, { +): Promise { + const { data } = await apiClient.post(`/admin/users/${id}/balance`, { balance, operation, notes: notes || '' @@ -127,7 +126,7 @@ export async function updateBalance( * @param concurrency - New concurrency limit * @returns Updated user */ -export async function updateConcurrency(id: number, concurrency: number): Promise { +export async function updateConcurrency(id: number, concurrency: number): Promise { return update(id, { concurrency }) } @@ -137,7 +136,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis * @param status - New status * @returns Updated user */ -export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise { +export async function toggleStatus(id: number, status: 'active' | 'disabled'): Promise { return update(id, { status }) } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index fb776e96..1f6b487b 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { Proxy, Group } from '@/types' +import type { Proxy, AdminGroup } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -659,7 +659,7 @@ interface Props { show: boolean accountIds: number[] proxies: Proxy[] - groups: Group[] + groups: AdminGroup[] } const props = defineProps() diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 7906cd6b..144241ff 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1816,7 +1816,7 @@ import { import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' -import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' +import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -1862,7 +1862,7 @@ const apiKeyHint = computed(() => { interface Props { show: boolean proxies: Proxy[] - groups: Group[] + groups: AdminGroup[] } const props = defineProps() diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 81d10932..0dd855ef 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -883,7 +883,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { adminAPI } from '@/api/admin' -import type { Account, Proxy, Group } from '@/types' +import type { Account, Proxy, AdminGroup } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import Select from '@/components/common/Select.vue' import Icon from '@/components/icons/Icon.vue' @@ -901,7 +901,7 @@ interface Props { show: boolean account: Account | null proxies: Proxy[] - groups: Group[] + groups: AdminGroup[] } const props = defineProps() diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index d2260c59..f6d1b1be 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format' import DataTable from '@/components/common/DataTable.vue' import EmptyState from '@/components/common/EmptyState.vue' import Icon from '@/components/icons/Icon.vue' -import type { UsageLog } from '@/types' +import type { AdminUsageLog } from '@/types' defineProps(['data', 'loading']) const { t } = useI18n() @@ -247,12 +247,12 @@ const { t } = useI18n() // Tooltip state - cost const tooltipVisible = ref(false) const tooltipPosition = ref({ x: 0, y: 0 }) -const tooltipData = ref(null) +const tooltipData = ref(null) // Tooltip state - token const tokenTooltipVisible = ref(false) const tokenTooltipPosition = ref({ x: 0, y: 0 }) -const tokenTooltipData = ref(null) +const tokenTooltipData = ref(null) const cols = computed(() => [ { key: 'user', label: t('admin.usage.user'), sortable: false }, @@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => { } // Cost tooltip functions -const showTooltip = (event: MouseEvent, row: UsageLog) => { +const showTooltip = (event: MouseEvent, row: AdminUsageLog) => { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() tooltipData.value = row @@ -311,7 +311,7 @@ const hideTooltip = () => { } // Token tooltip functions -const showTokenTooltip = (event: MouseEvent, row: UsageLog) => { +const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => { const target = event.currentTarget as HTMLElement const rect = target.getBoundingClientRect() tokenTooltipData.value = row diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index c1783fd2..825d2be5 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -39,10 +39,10 @@ import { ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { User, Group } from '@/types' +import type { AdminUser, Group } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' -const props = defineProps<{ show: boolean, user: User | null }>() +const props = defineProps<{ show: boolean, user: AdminUser | null }>() const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore() const groups = ref([]); const selectedIds = ref([]); const loading = ref(false); const submitting = ref(false) @@ -56,4 +56,4 @@ const handleSave = async () => { appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close') } catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false } } - \ No newline at end of file + diff --git a/frontend/src/components/admin/user/UserApiKeysModal.vue b/frontend/src/components/admin/user/UserApiKeysModal.vue index ef098ba1..c2159ff4 100644 --- a/frontend/src/components/admin/user/UserApiKeysModal.vue +++ b/frontend/src/components/admin/user/UserApiKeysModal.vue @@ -32,10 +32,10 @@ import { ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' import { formatDateTime } from '@/utils/format' -import type { User, ApiKey } from '@/types' +import type { AdminUser, ApiKey } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' -const props = defineProps<{ show: boolean, user: User | null }>() +const props = defineProps<{ show: boolean, user: AdminUser | null }>() defineEmits(['close']); const { t } = useI18n() const apiKeys = ref([]); const loading = ref(false) @@ -44,4 +44,4 @@ const load = async () => { if (!props.user) return; loading.value = true try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false } } - \ No newline at end of file + diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue index c669c2a5..143350bf 100644 --- a/frontend/src/components/admin/user/UserBalanceModal.vue +++ b/frontend/src/components/admin/user/UserBalanceModal.vue @@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' -import type { User } from '@/types' +import type { AdminUser } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' -const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>() +const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>() const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore() const submitting = ref(false); const form = reactive({ amount: 0, notes: '' }) diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 2c4b117a..70ebd2d3 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { useClipboard } from '@/composables/useClipboard' import { adminAPI } from '@/api/admin' -import type { User, UserAttributeValuesMap } from '@/types' +import type { AdminUser, UserAttributeValuesMap } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' import UserAttributeForm from '@/components/user/UserAttributeForm.vue' import Icon from '@/components/icons/Icon.vue' -const props = defineProps<{ show: boolean, user: User | null }>() +const props = defineProps<{ show: boolean, user: AdminUser | null }>() const emit = defineEmits(['close', 'success']) const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard() diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index c67d32fc..d5f950f2 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -42,13 +42,13 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import GroupBadge from './GroupBadge.vue' -import type { Group, GroupPlatform } from '@/types' +import type { AdminGroup, GroupPlatform } from '@/types' const { t } = useI18n() interface Props { modelValue: number[] - groups: Group[] + groups: AdminGroup[] platform?: GroupPlatform // Optional platform filter mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 769a4206..1b7ae15d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -27,7 +27,6 @@ export interface FetchOptions { export interface User { id: number username: string - notes: string email: string role: 'admin' | 'user' // User role for authorization balance: number // User balance for API usage @@ -39,6 +38,11 @@ export interface User { updated_at: string } +export interface AdminUser extends User { + // 管理员备注(普通用户接口不返回) + notes: string +} + export interface LoginRequest { email: string password: string @@ -270,14 +274,19 @@ export interface Group { // Claude Code 客户端限制 claude_code_only: boolean fallback_group_id: number | null - // 模型路由配置(仅 anthropic 平台使用) - model_routing: Record | null - model_routing_enabled: boolean - account_count?: number created_at: string updated_at: string } +export interface AdminGroup extends Group { + // 模型路由配置(仅管理员可见,内部信息) + model_routing: Record | null + model_routing_enabled: boolean + + // 分组下账号数量(仅管理员可见) + account_count?: number +} + export interface ApiKey { id: number user_id: number @@ -637,7 +646,6 @@ export interface UsageLog { total_cost: number actual_cost: number rate_multiplier: number - account_rate_multiplier?: number | null billing_type: number stream: boolean @@ -651,18 +659,30 @@ export interface UsageLog { // User-Agent user_agent: string | null - // IP 地址(仅管理员可见) - ip_address: string | null - created_at: string user?: User api_key?: ApiKey - account?: Account group?: Group subscription?: UserSubscription } +export interface UsageLogAccountSummary { + id: number + name: string +} + +export interface AdminUsageLog extends UsageLog { + // 账号计费倍率(仅管理员可见) + account_rate_multiplier?: number | null + + // 用户请求 IP(仅管理员可见) + ip_address?: string | null + + // 最小账号信息(仅管理员接口返回) + account?: UsageLogAccountSummary +} + export interface UsageCleanupFilters { start_time: string end_time: string diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 1d949e9b..c11675b7 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -187,14 +187,14 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import Icon from '@/components/icons/Icon.vue' import { formatDateTime, formatRelativeTime } from '@/utils/format' -import type { Account, Proxy, Group } from '@/types' +import type { Account, Proxy, AdminGroup } from '@/types' const { t } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() const proxies = ref([]) -const groups = ref([]) +const groups = ref([]) const selIds = ref([]) const showCreate = ref(false) const showEdit = ref(false) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 47a15084..78ef2e48 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1107,7 +1107,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { useOnboardingStore } from '@/stores/onboarding' import { adminAPI } from '@/api/admin' -import type { Group, GroupPlatform, SubscriptionType } from '@/types' +import type { AdminGroup, GroupPlatform, SubscriptionType } from '@/types' import type { Column } from '@/components/common/types' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' @@ -1202,7 +1202,7 @@ const fallbackGroupOptionsForEdit = computed(() => { return options }) -const groups = ref([]) +const groups = ref([]) const loading = ref(false) const searchQuery = ref('') const filters = reactive({ @@ -1223,8 +1223,8 @@ const showCreateModal = ref(false) const showEditModal = ref(false) const showDeleteDialog = ref(false) const submitting = ref(false) -const editingGroup = ref(null) -const deletingGroup = ref(null) +const editingGroup = ref(null) +const deletingGroup = ref(null) const createForm = reactive({ name: '', @@ -1529,7 +1529,7 @@ const handleCreateGroup = async () => { } } -const handleEdit = async (group: Group) => { +const handleEdit = async (group: AdminGroup) => { editingGroup.value = group editForm.name = group.name editForm.description = group.description || '' @@ -1585,7 +1585,7 @@ const handleUpdateGroup = async () => { } } -const handleDelete = (group: Group) => { +const handleDelete = (group: AdminGroup) => { deletingGroup.value = group showDeleteDialog.value = true } diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 40b63ec3..9904405b 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -42,11 +42,11 @@ import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; impo import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue' import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue' -import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage' +import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage' const { t } = useI18n() const appStore = useAppStore() -const usageStats = ref(null); const usageLogs = ref([]); const loading = ref(false); const exporting = ref(false) +const usageStats = ref(null); const usageLogs = ref([]); const loading = ref(false); const exporting = ref(false) const trendData = ref([]); const modelStats = ref([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day') let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' }) @@ -92,7 +92,7 @@ const exportToExcel = async () => { if (exporting.value) return; exporting.value = true; exportProgress.show = true const c = new AbortController(); exportAbortController = c try { - const all: UsageLog[] = []; let p = 1; let total = pagination.total + const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total while (true) { const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal }) if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 4ce839f1..2a73a977 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -492,7 +492,7 @@ import Icon from '@/components/icons/Icon.vue' const { t } = useI18n() import { adminAPI } from '@/api/admin' -import type { User, UserAttributeDefinition } from '@/types' +import type { AdminUser, UserAttributeDefinition } from '@/types' import type { BatchUserUsageStats } from '@/api/admin/dashboard' import type { Column } from '@/components/common/types' import AppLayout from '@/components/layout/AppLayout.vue' @@ -637,7 +637,7 @@ const columns = computed(() => ) ) -const users = ref([]) +const users = ref([]) const loading = ref(false) const searchQuery = ref('') @@ -736,16 +736,16 @@ const showEditModal = ref(false) const showDeleteDialog = ref(false) const showApiKeysModal = ref(false) const showAttributesModal = ref(false) -const editingUser = ref(null) -const deletingUser = ref(null) -const viewingUser = ref(null) +const editingUser = ref(null) +const deletingUser = ref(null) +const viewingUser = ref(null) let abortController: AbortController | null = null // Action Menu State const activeMenuId = ref(null) const menuPosition = ref<{ top: number; left: number } | null>(null) -const openActionMenu = (user: User, e: MouseEvent) => { +const openActionMenu = (user: AdminUser, e: MouseEvent) => { if (activeMenuId.value === user.id) { closeActionMenu() } else { @@ -821,11 +821,11 @@ const handleClickOutside = (event: MouseEvent) => { // Allowed groups modal state const showAllowedGroupsModal = ref(false) -const allowedGroupsUser = ref(null) +const allowedGroupsUser = ref(null) // Balance (Deposit/Withdraw) modal state const showBalanceModal = ref(false) -const balanceUser = ref(null) +const balanceUser = ref(null) const balanceOperation = ref<'add' | 'subtract'>('add') // 计算剩余天数 @@ -998,7 +998,7 @@ const applyFilter = () => { loadUsers() } -const handleEdit = (user: User) => { +const handleEdit = (user: AdminUser) => { editingUser.value = user showEditModal.value = true } @@ -1008,7 +1008,7 @@ const closeEditModal = () => { editingUser.value = null } -const handleToggleStatus = async (user: User) => { +const handleToggleStatus = async (user: AdminUser) => { const newStatus = user.status === 'active' ? 'disabled' : 'active' try { await adminAPI.users.toggleStatus(user.id, newStatus) @@ -1022,7 +1022,7 @@ const handleToggleStatus = async (user: User) => { } } -const handleViewApiKeys = (user: User) => { +const handleViewApiKeys = (user: AdminUser) => { viewingUser.value = user showApiKeysModal.value = true } @@ -1032,7 +1032,7 @@ const closeApiKeysModal = () => { viewingUser.value = null } -const handleAllowedGroups = (user: User) => { +const handleAllowedGroups = (user: AdminUser) => { allowedGroupsUser.value = user showAllowedGroupsModal.value = true } @@ -1042,7 +1042,7 @@ const closeAllowedGroupsModal = () => { allowedGroupsUser.value = null } -const handleDelete = (user: User) => { +const handleDelete = (user: AdminUser) => { deletingUser.value = user showDeleteDialog.value = true } @@ -1061,13 +1061,13 @@ const confirmDelete = async () => { } } -const handleDeposit = (user: User) => { +const handleDeposit = (user: AdminUser) => { balanceUser.value = user balanceOperation.value = 'add' showBalanceModal.value = true } -const handleWithdraw = (user: User) => { +const handleWithdraw = (user: AdminUser) => { balanceUser.value = user balanceOperation.value = 'subtract' showBalanceModal.value = true