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/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 202b6869..3a3a18b2 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -72,36 +72,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 +105,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 diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e0dd50ca..60e7c9bf 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -58,13 +58,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"` } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f551c7f9..814668d3 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -131,6 +131,62 @@ 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/usage/stats", setup: func(t *testing.T, deps *contractDeps) { @@ -385,6 +441,8 @@ type contractDeps struct { now time.Time router http.Handler apiKeyRepo *stubApiKeyRepo + groupRepo *stubGroupRepo + userSubRepo *stubUserSubscriptionRepo usageRepo *stubUsageLogRepo settingRepo *stubSettingRepo } @@ -414,11 +472,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{ @@ -472,6 +530,7 @@ 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) @@ -487,6 +546,8 @@ func newContractDeps(t *testing.T) *contractDeps { now: now, router: r, apiKeyRepo: apiKeyRepo, + groupRepo: groupRepo, + userSubRepo: userSubRepo, usageRepo: usageRepo, settingRepo: settingRepo, } @@ -626,7 +687,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") @@ -660,12 +727,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) { @@ -925,7 +999,24 @@ func (stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit in return nil, errors.New("not implemented") } -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") @@ -945,11 +1036,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/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/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 1149b6c6..fbf41898 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -269,14 +269,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 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 }