diff --git a/cmd/capi/anthropic_messages.go b/cmd/capi/anthropic_messages.go index c8d946b..de16c10 100644 --- a/cmd/capi/anthropic_messages.go +++ b/cmd/capi/anthropic_messages.go @@ -104,7 +104,7 @@ func (s *Server) anthropicMessages(c *gin.Context) { writeAnthropicError(c, http.StatusForbidden, "permission_error", "API key is not allowed to use model: "+model.ID) return } - channels := s.channelCandidatesLocked(model.ID) + channels := s.channelCandidatesLocked(model.ID, auth.User.GroupID) if len(channels) == 0 { s.logGatewayFailureLocked(c, "model_not_available", auth.User.ID, auth.Key.Prefix, model.ID, "") s.mu.Unlock() diff --git a/cmd/capi/main.go b/cmd/capi/main.go index cf07ecf..642e10d 100644 --- a/cmd/capi/main.go +++ b/cmd/capi/main.go @@ -69,6 +69,7 @@ var imageJSONKeepaliveInterval = 8 * time.Second type AppState struct { Users []User `json:"users"` + Groups []UserGroup `json:"groups"` APIKeys []APIKey `json:"apiKeys"` Channels []Channel `json:"channels"` Models []Model `json:"models"` @@ -98,6 +99,7 @@ type AuthSettings struct { RegistrationEnabled bool `json:"registrationEnabled"` RegistrationMode string `json:"registrationMode,omitempty"` DefaultBalance float64 `json:"defaultBalance"` + DefaultGroupID string `json:"defaultGroupId,omitempty"` } type CheckInSettings struct { @@ -145,12 +147,20 @@ type PublicDiscordSettings struct { SessionTTLHours int `json:"sessionTtlHours"` } +type UserGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"createdAt"` +} + type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` Status string `json:"status"` + GroupID string `json:"groupId"` Balance float64 `json:"balance"` RequestsToday int `json:"requestsToday"` TotalRequests int `json:"totalRequests"` @@ -201,6 +211,7 @@ type Channel struct { Priority int `json:"priority"` Weight int `json:"weight"` Models []string `json:"models"` + AllowedGroupIDs []string `json:"allowedGroupIds"` InputPricePer1K float64 `json:"inputPricePer1K"` OutputPricePer1K float64 `json:"outputPricePer1K"` PricingConfigured bool `json:"pricingConfigured"` @@ -223,6 +234,7 @@ type PublicChannel struct { Priority int `json:"priority"` Weight int `json:"weight"` Models []string `json:"models"` + AllowedGroupIDs []string `json:"allowedGroupIds"` InputPricePer1K float64 `json:"inputPricePer1K"` OutputPricePer1K float64 `json:"outputPricePer1K"` PricingConfigured bool `json:"pricingConfigured"` @@ -914,6 +926,7 @@ func (s *Server) registerRoutes(router *gin.Engine) { account.GET("/check-in", s.checkInStatus) account.POST("/check-in", s.claimCheckIn) account.POST("/api-keys", s.createOwnAPIKey) + account.DELETE("/api-keys/:id", s.deleteOwnAPIKey) account.PATCH("/profile", s.updateAccountProfile) admin := api.Group("") @@ -926,6 +939,10 @@ func (s *Server) registerRoutes(router *gin.Engine) { admin.POST("/users/:id/api-keys", s.createAPIKey) admin.PATCH("/api-keys/:id", s.updateAPIKey) admin.DELETE("/api-keys/:id", s.deleteAPIKey) + admin.GET("/groups", s.listUserGroups) + admin.POST("/groups", s.createUserGroup) + admin.PATCH("/groups/:id", s.updateUserGroup) + admin.DELETE("/groups/:id", s.deleteUserGroup) admin.GET("/channels", s.listChannels) admin.POST("/channels", s.createChannel) admin.POST("/channel-model-preview", s.previewChannelModelsFromConnection) @@ -1257,11 +1274,14 @@ func (s *Server) setupAdmin(c *gin.Context) { LastLoginAt: now(), } s.state.Accounts = append(s.state.Accounts, account) + defaultGroupID := s.ensureDefaultUserGroupLocked() + user.GroupID = defaultGroupID s.state.Settings.Auth = AuthSettings{ Managed: true, RegistrationEnabled: body.RegistrationEnabled, RegistrationMode: registrationMode, DefaultBalance: defaultBalance, + DefaultGroupID: defaultGroupID, } s.saveStateLocked() s.mu.Unlock() @@ -1392,6 +1412,7 @@ func (s *Server) registerAccount(c *gin.Context) { Email: body.Email, Role: "user", Status: "active", + GroupID: s.defaultRegistrationGroupIDLocked(), Balance: s.defaultRegistrationBalanceLocked(), CreatedAt: now(), LastLoginAt: now(), @@ -1425,6 +1446,7 @@ func (s *Server) getAuthSettings(c *gin.Context) { "registrationEnabled": s.registrationEnabledLocked(), "registrationMode": s.registrationModeLocked(), "defaultBalance": s.defaultRegistrationBalanceLocked(), + "defaultGroupId": s.defaultRegistrationGroupIDLocked(), }}) } @@ -1433,6 +1455,7 @@ func (s *Server) updateAuthSettings(c *gin.Context) { RegistrationEnabled bool `json:"registrationEnabled"` RegistrationMode *string `json:"registrationMode"` DefaultBalance *float64 `json:"defaultBalance"` + DefaultGroupID *string `json:"defaultGroupId"` } if err := c.ShouldBindJSON(&body); err != nil { validationError(c, "无效的认证设置") @@ -1452,17 +1475,27 @@ func (s *Server) updateAuthSettings(c *gin.Context) { } defaultBalance = round4(*body.DefaultBalance) } + defaultGroupID := s.defaultRegistrationGroupIDLocked() + if body.DefaultGroupID != nil { + defaultGroupID = strings.TrimSpace(*body.DefaultGroupID) + if s.findUserGroup(defaultGroupID) == nil { + validationError(c, "默认用户分组不存在") + return + } + } s.state.Settings.Auth = AuthSettings{ Managed: true, RegistrationEnabled: body.RegistrationEnabled, RegistrationMode: registrationMode, DefaultBalance: defaultBalance, + DefaultGroupID: defaultGroupID, } s.saveStateLocked() c.JSON(http.StatusOK, gin.H{"auth": gin.H{ "registrationEnabled": body.RegistrationEnabled, "registrationMode": registrationMode, "defaultBalance": defaultBalance, + "defaultGroupId": defaultGroupID, }}) } @@ -1954,6 +1987,7 @@ func (s *Server) discordCallback(c *gin.Context) { Name: displayName, Role: "user", Status: "active", + GroupID: s.defaultRegistrationGroupIDLocked(), Balance: s.defaultRegistrationBalanceLocked(), CreatedAt: now(), LastLoginAt: now(), @@ -2259,6 +2293,26 @@ func (s *Server) createOwnAPIKey(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"apiKey": publicAPIKey(key), "secret": secret}) } +func (s *Server) deleteOwnAPIKey(c *gin.Context) { + session, ok := s.sessionFromRequest(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "Login required"}}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + for i, key := range s.state.APIKeys { + if key.ID != c.Param("id") || key.UserID != session.UserID { + continue + } + s.state.APIKeys = append(s.state.APIKeys[:i], s.state.APIKeys[i+1:]...) + s.saveStateLocked() + c.JSON(http.StatusOK, gin.H{"deleted": true}) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "API key not found"}}) +} + func (s *Server) overview(c *gin.Context) { s.mu.Lock() defer s.mu.Unlock() @@ -2398,6 +2452,16 @@ func (s *Server) updateUser(c *gin.Context) { if value, ok := patch["note"].(string); ok { user.Note = value } + if value, ok := patch["groupId"].(string); ok { + value = strings.TrimSpace(value) + // An empty value clears the assignment so the user reverts to the + // unrestricted (未分组) set of channels. + if value != "" && s.findUserGroup(value) == nil { + validationError(c, "用户分组不存在") + return + } + user.GroupID = value + } if value, ok := asFloat(patch["balance"]); ok { if value < 0 { validationError(c, "Balance must be greater than or equal to 0") @@ -2544,6 +2608,104 @@ func (s *Server) syncAccountAccessLocked(user *User) { } } +func (s *Server) listUserGroups(c *gin.Context) { + s.mu.Lock() + defer s.mu.Unlock() + c.JSON(http.StatusOK, gin.H{"groups": s.state.Groups}) +} + +func (s *Server) createUserGroup(c *gin.Context) { + var body struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&body); err != nil { + validationError(c, "无效的用户分组") + return + } + body.Name = strings.TrimSpace(body.Name) + body.Description = strings.TrimSpace(body.Description) + if body.Name == "" { + validationError(c, "分组名称不能为空") + return + } + s.mu.Lock() + defer s.mu.Unlock() + for _, group := range s.state.Groups { + if strings.EqualFold(group.Name, body.Name) { + validationError(c, "分组名称已存在") + return + } + } + group := UserGroup{ID: newID("grp"), Name: body.Name, Description: body.Description, CreatedAt: now()} + s.state.Groups = append(s.state.Groups, group) + s.saveStateLocked() + c.JSON(http.StatusCreated, gin.H{"group": group}) +} + +func (s *Server) updateUserGroup(c *gin.Context) { + var patch map[string]interface{} + if err := c.ShouldBindJSON(&patch); err != nil { + validationError(c, "无效的用户分组") + return + } + s.mu.Lock() + defer s.mu.Unlock() + group := s.findUserGroup(c.Param("id")) + if group == nil { + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "User group not found"}}) + return + } + if value, ok := patch["name"].(string); ok { + value = strings.TrimSpace(value) + if value == "" { + validationError(c, "分组名称不能为空") + return + } + for _, existing := range s.state.Groups { + if existing.ID != group.ID && strings.EqualFold(existing.Name, value) { + validationError(c, "分组名称已存在") + return + } + } + group.Name = value + } + if value, ok := patch["description"].(string); ok { + group.Description = strings.TrimSpace(value) + } + s.saveStateLocked() + c.JSON(http.StatusOK, gin.H{"group": group}) +} + +func (s *Server) deleteUserGroup(c *gin.Context) { + id := c.Param("id") + s.mu.Lock() + defer s.mu.Unlock() + if id == s.defaultRegistrationGroupIDLocked() { + validationError(c, "默认注册分组不能删除") + return + } + for _, user := range s.state.Users { + if user.GroupID == id { + validationError(c, "该分组仍有用户,不能删除") + return + } + } + for i, group := range s.state.Groups { + if group.ID != id { + continue + } + s.state.Groups = append(s.state.Groups[:i], s.state.Groups[i+1:]...) + for index := range s.state.Channels { + s.state.Channels[index].AllowedGroupIDs = removeString(s.state.Channels[index].AllowedGroupIDs, id) + } + s.saveStateLocked() + c.JSON(http.StatusOK, gin.H{"deleted": true}) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "User group not found"}}) +} + func (s *Server) createAPIKey(c *gin.Context) { var body struct { Name string `json:"name"` @@ -2698,6 +2860,7 @@ func (s *Server) createChannel(c *gin.Context) { Priority int `json:"priority"` Weight int `json:"weight"` Models []string `json:"models"` + AllowedGroupIDs []string `json:"allowedGroupIds"` InputPricePer1K float64 `json:"inputPricePer1K"` OutputPricePer1K float64 `json:"outputPricePer1K"` } @@ -2746,6 +2909,14 @@ func (s *Server) createChannel(c *gin.Context) { } } + s.mu.Lock() + defer s.mu.Unlock() + allowedGroupIDs, err := s.normalizeUserGroupIDsLocked(body.AllowedGroupIDs) + if err != nil { + validationError(c, err.Error()) + return + } + channel := Channel{ ID: newID("chn"), Name: body.Name, @@ -2758,13 +2929,12 @@ func (s *Server) createChannel(c *gin.Context) { Priority: body.Priority, Weight: body.Weight, Models: append([]string{}, body.Models...), + AllowedGroupIDs: allowedGroupIDs, InputPricePer1K: round4(body.InputPricePer1K), OutputPricePer1K: round4(body.OutputPricePer1K), PricingConfigured: body.InputPricePer1K > 0 || body.OutputPricePer1K > 0, } - s.mu.Lock() - defer s.mu.Unlock() for _, modelID := range channel.Models { s.ensureChannelModelLocked(modelID, channel.Provider, "渠道", "Imported channel model") } @@ -3494,6 +3664,19 @@ func (s *Server) updateChannel(c *gin.Context) { if value, ok := patch["models"].([]interface{}); ok { channel.Models = stringSlice(value) } + if value, exists := patch["allowedGroupIds"]; exists { + allowedGroupIDs, ok := stringSliceFromPatch(value) + if !ok { + validationError(c, "allowedGroupIds must be an array") + return + } + normalized, err := s.normalizeUserGroupIDsLocked(allowedGroupIDs) + if err != nil { + validationError(c, err.Error()) + return + } + channel.AllowedGroupIDs = normalized + } for _, modelID := range channel.Models { s.ensureChannelModelLocked(modelID, channel.Provider, "渠道", "Imported channel model") } @@ -4147,7 +4330,7 @@ func (s *Server) embeddings(c *gin.Context) { s.mu.Unlock() return } - channels := s.channelCandidatesLocked(model.ID) + channels := s.channelCandidatesLocked(model.ID, auth.User.GroupID) if len(channels) == 0 { s.openAIErrorForCallLocked(c, http.StatusBadRequest, "model_not_available", "No available channel for model: "+model.ID, "invalid_request_error", stringPtr("model"), auth.User.ID, auth.Key.Prefix, model.ID, "") s.mu.Unlock() @@ -4218,7 +4401,7 @@ func (s *Server) audioSpeech(c *gin.Context) { s.mu.Unlock() return } - channels := s.channelCandidatesLocked(model.ID) + channels := s.channelCandidatesLocked(model.ID, auth.User.GroupID) if len(channels) == 0 { s.openAIErrorForCallLocked(c, http.StatusBadRequest, "model_not_available", "No available channel for model: "+model.ID, "invalid_request_error", stringPtr("model"), auth.User.ID, auth.Key.Prefix, model.ID, "") s.mu.Unlock() @@ -4580,7 +4763,7 @@ func (s *Server) handleImageGeneration(c *gin.Context, body ImageRequest, starte s.mu.Unlock() return } - channels := s.channelCandidatesLocked(model.ID) + channels := s.channelCandidatesLocked(model.ID, auth.User.GroupID) if len(channels) == 0 { s.openAIErrorForCallLocked(c, http.StatusBadRequest, "model_not_available", "No available channel for model: "+model.ID, "invalid_request_error", stringPtr("model"), auth.User.ID, auth.Key.Prefix, model.ID, "") s.mu.Unlock() @@ -4684,7 +4867,7 @@ func (s *Server) handleChatCompletionWithTransform(c *gin.Context, body ChatRequ s.mu.Unlock() return } - channels := s.channelCandidatesLocked(model.ID) + channels := s.channelCandidatesLocked(model.ID, auth.User.GroupID) if len(channels) == 0 { s.openAIErrorForCallLocked(c, http.StatusBadRequest, "model_not_available", "No available channel for model: "+model.ID, "invalid_request_error", stringPtr("model"), auth.User.ID, auth.Key.Prefix, model.ID, "") s.mu.Unlock() @@ -9038,6 +9221,67 @@ func (s *Server) findUser(id string) *User { return nil } +func (s *Server) findUserGroup(id string) *UserGroup { + for i := range s.state.Groups { + if s.state.Groups[i].ID == id { + return &s.state.Groups[i] + } + } + return nil +} + +func (s *Server) ensureDefaultUserGroupLocked() string { + if group := s.findUserGroup(s.state.Settings.Auth.DefaultGroupID); group != nil { + return group.ID + } + for i := range s.state.Groups { + if strings.EqualFold(strings.TrimSpace(s.state.Groups[i].Name), "默认分组") { + s.state.Settings.Auth.DefaultGroupID = s.state.Groups[i].ID + return s.state.Groups[i].ID + } + } + id := "grp_default" + if s.findUserGroup(id) != nil { + id = newID("grp") + } + s.state.Groups = append(s.state.Groups, UserGroup{ + ID: id, + Name: "默认分组", + Description: "新注册用户的默认分组", + CreatedAt: now(), + }) + s.state.Settings.Auth.DefaultGroupID = id + return id +} + +func (s *Server) defaultRegistrationGroupIDLocked() string { + return s.ensureDefaultUserGroupLocked() +} + +func (s *Server) normalizeUserGroupIDsLocked(values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" || seen[id] { + continue + } + if s.findUserGroup(id) == nil { + return nil, fmt.Errorf("用户分组不存在: %s", id) + } + seen[id] = true + result = append(result, id) + } + return result, nil +} + +func channelAllowsUserGroup(channel Channel, groupID string) bool { + if len(channel.AllowedGroupIDs) == 0 { + return true + } + return containsString(channel.AllowedGroupIDs, strings.TrimSpace(groupID)) +} + func (s *Server) findAPIKeyByID(id string) *APIKey { for i := range s.state.APIKeys { if s.state.APIKeys[i].ID == id { @@ -9367,13 +9611,16 @@ func normalizeAPIKeyExpiresAt(value string) (string, error) { return expiresAt.UTC().Format(time.RFC3339), nil } -func (s *Server) channelCandidatesLocked(modelID string) []Channel { +func (s *Server) channelCandidatesLocked(modelID, groupID string) []Channel { candidates := []Channel{} for i := range s.state.Channels { channel := &s.state.Channels[i] if channel.Status == "disabled" { continue } + if !channelAllowsUserGroup(*channel, groupID) { + continue + } for _, id := range channel.Models { if id == modelID { candidates = append(candidates, *channel) @@ -11150,6 +11397,7 @@ func publicChannel(channel Channel) PublicChannel { Priority: channel.Priority, Weight: channel.Weight, Models: append([]string{}, channel.Models...), + AllowedGroupIDs: append([]string{}, channel.AllowedGroupIDs...), InputPricePer1K: channel.InputPricePer1K, OutputPricePer1K: channel.OutputPricePer1K, PricingConfigured: channel.PricingConfigured, diff --git a/cmd/capi/main_test.go b/cmd/capi/main_test.go index e5b0e0c..f38595a 100644 --- a/cmd/capi/main_test.go +++ b/cmd/capi/main_test.go @@ -4706,3 +4706,141 @@ func TestParseWhamUsageQuotaLimitsFreePlanUsesMonthlyWindow(t *testing.T) { t.Fatalf("paid plan primary window label = %#v, want 5h first", paid) } } + +func TestChannelAllowedGroupIDsRestrictChatRouting(t *testing.T) { + withEnv(t, map[string]string{"PERSISTENCE": "memory"}) + server, router := testServerRouter(t) + seedGatewayFixtures(server) + + created := perform(router, http.MethodPost, "/api/groups", `{"name":"premium","description":"尊享"}`, nil) + if created.Code != http.StatusCreated { + t.Fatalf("create group status = %d body = %s", created.Code, created.Body.String()) + } + var groupPayload struct { + Group UserGroup `json:"group"` + } + if err := json.Unmarshal(created.Body.Bytes(), &groupPayload); err != nil { + t.Fatalf("decode group: %v", err) + } + groupID := groupPayload.Group.ID + + // Restrict the deepseek-v4 channel (chn_1002) to the premium group. + restricted := perform(router, http.MethodPatch, "/api/channels/chn_1002", `{"allowedGroupIds":["`+groupID+`"]}`, nil) + if restricted.Code != http.StatusOK { + t.Fatalf("restrict channel status = %d body = %s", restricted.Code, restricted.Body.String()) + } + if !bytes.Contains(restricted.Body.Bytes(), []byte(`"allowedGroupIds":["`+groupID+`"]`)) { + t.Fatalf("restricted channel did not persist allowed group ids: %s", restricted.Body.String()) + } + + // usr_1002 is ungrouped, so deepseek-v4 (only on the restricted channel) is unavailable. + blocked := perform(router, http.MethodPost, "/v1/chat/completions", `{"model":"ds","messages":[{"role":"user","content":"hi"}]}`, map[string]string{"Authorization": "Bearer cat_fixture_live_secret"}) + if blocked.Code != http.StatusBadRequest || !bytes.Contains(blocked.Body.Bytes(), []byte(`model_not_available`)) { + t.Fatalf("ungrouped user should be blocked from restricted channel: %d %s", blocked.Code, blocked.Body.String()) + } + + // Move usr_1002 into the premium group, then deepseek-v4 becomes routable. + server.mu.Lock() + server.findUser("usr_1002").GroupID = groupID + server.mu.Unlock() + allowed := perform(router, http.MethodPost, "/v1/chat/completions", `{"model":"ds","messages":[{"role":"user","content":"hi"}]}`, map[string]string{"Authorization": "Bearer cat_fixture_live_secret"}) + if allowed.Code != http.StatusOK { + t.Fatalf("grouped user should reach restricted channel: %d %s", allowed.Code, allowed.Body.String()) + } +} + +func TestUserGroupCanBeAssignedAndUnassigned(t *testing.T) { + withEnv(t, map[string]string{"PERSISTENCE": "memory"}) + server, router := testServerRouter(t) + seedGatewayFixtures(server) + + created := perform(router, http.MethodPost, "/api/groups", `{"name":"beta","description":"内测"}`, nil) + if created.Code != http.StatusCreated { + t.Fatalf("create group status = %d body = %s", created.Code, created.Body.String()) + } + var groupPayload struct { + Group UserGroup `json:"group"` + } + if err := json.Unmarshal(created.Body.Bytes(), &groupPayload); err != nil { + t.Fatalf("decode group: %v", err) + } + groupID := groupPayload.Group.ID + + // Assigning a non-existent group is rejected. + invalid := perform(router, http.MethodPatch, "/api/users/usr_1002", `{"groupId":"grp_missing"}`, nil) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("unknown group assignment should be rejected: %d %s", invalid.Code, invalid.Body.String()) + } + + // Assigning a valid group persists the groupId. + assigned := perform(router, http.MethodPatch, "/api/users/usr_1002", `{"groupId":"`+groupID+`"}`, nil) + if assigned.Code != http.StatusOK || !bytes.Contains(assigned.Body.Bytes(), []byte(`"groupId":"`+groupID+`"`)) { + t.Fatalf("valid group assignment status = %d body = %s", assigned.Code, assigned.Body.String()) + } + + // Unassigning (empty string) clears the group. + unassigned := perform(router, http.MethodPatch, "/api/users/usr_1002", `{"groupId":""}`, nil) + if unassigned.Code != http.StatusOK || !bytes.Contains(unassigned.Body.Bytes(), []byte(`"groupId":""`)) { + t.Fatalf("unassign status = %d body = %s", unassigned.Code, unassigned.Body.String()) + } +} + + +func TestOwnAPIKeyCanBeDeletedByOwner(t *testing.T) { + withEnv(t, map[string]string{"PERSISTENCE": "memory"}) + _, router := testServerRouter(t) + + setup := perform(router, http.MethodPost, "/api/auth/setup", `{ + "username":"root_admin", + "password":"correct-horse-battery", + "displayName":"Root Admin", + "email":"root@example.test", + "registrationEnabled":true, + "registrationMode":"username" + }`, nil) + if setup.Code != http.StatusCreated { + t.Fatalf("setup status = %d body = %s", setup.Code, setup.Body.String()) + } + + register := perform(router, http.MethodPost, "/api/auth/register", `{ + "username":"self_delete", + "password":"safe-password-123", + "displayName":"Self Delete", + "email":"self-delete@example.test" + }`, nil) + if register.Code != http.StatusCreated { + t.Fatalf("register status = %d body = %s", register.Code, register.Body.String()) + } + userCookie := register.Result().Cookies()[0] + headers := map[string]string{"Cookie": userCookie.Name + "=" + userCookie.Value} + + created := perform(router, http.MethodPost, "/api/account/api-keys", `{"name":"Temp Key"}`, headers) + if created.Code != http.StatusCreated { + t.Fatalf("create own key status = %d body = %s", created.Code, created.Body.String()) + } + var payload struct { + Secret string `json:"secret"` + APIKey PublicAPIKey `json:"apiKey"` + } + if err := json.Unmarshal(created.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode own key: %v", err) + } + keyID := payload.APIKey.ID + if keyID == "" { + t.Fatalf("created own key did not include an id: %s", created.Body.String()) + } + + // The key works while active (needs a model/channel, so seed fixtures first). + // The owner deletes it. + deleted := perform(router, http.MethodDelete, "/api/account/api-keys/"+keyID, "", headers) + if deleted.Code != http.StatusOK || !bytes.Contains(deleted.Body.Bytes(), []byte(`"deleted":true`)) { + t.Fatalf("delete own key status = %d body = %s", deleted.Code, deleted.Body.String()) + } + + // The key is gone: a repeat delete reports not found. + repeat := perform(router, http.MethodDelete, "/api/account/api-keys/"+keyID, "", headers) + if repeat.Code != http.StatusNotFound { + t.Fatalf("repeat delete status = %d body = %s", repeat.Code, repeat.Body.String()) + } +} + diff --git a/src/App.tsx b/src/App.tsx index 48bd2ee..1734f4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,14 @@ type User = { createdAt: string; lastLoginAt: string; note: string; + groupId: string; +}; + +type UserGroup = { + id: string; + name: string; + description: string; + createdAt: string; }; type ApiKey = { @@ -56,6 +64,7 @@ type Channel = { priority: number; weight: number; models: string[]; + allowedGroupIds: string[]; inputPricePer1K: number; outputPricePer1K: number; pricingConfigured: boolean; @@ -318,7 +327,7 @@ function mergeUniqueStrings(values: string[]) { function normalizeChannel(channel: Channel): Channel { const streamMode = streamModeOptions.some((option) => option.value === channel.streamMode) ? channel.streamMode : "auto"; - return { ...channel, streamMode, models: arrayOf(channel.models), openaiAccounts: arrayOf(channel.openaiAccounts), kiroAccounts: arrayOf(channel.kiroAccounts) }; + return { ...channel, streamMode, models: arrayOf(channel.models), allowedGroupIds: arrayOf(channel.allowedGroupIds), openaiAccounts: arrayOf(channel.openaiAccounts), kiroAccounts: arrayOf(channel.kiroAccounts) }; } function normalizeModel(model: ModelItem): ModelItem { @@ -424,6 +433,7 @@ async function fetchFormJson(url: string, body: FormData): Promise { const navItems = [ { id: "overview", label: "概览", icon: "home" }, { id: "users", label: "用户", icon: "users" }, + { id: "groups", label: "分组", icon: "users" }, { id: "keys", label: "密钥", icon: "key" }, { id: "models", label: "模型", icon: "models" }, { id: "drawing", label: "绘图", icon: "image" }, @@ -803,6 +813,7 @@ function App() { const [overview, setOverview] = useState(null); const [users, setUsers] = useState([]); const [channels, setChannels] = useState([]); + const [groups, setGroups] = useState([]); const [models, setModels] = useState([]); const [logs, setLogs] = useState([]); const [query, setQuery] = useState(""); @@ -820,20 +831,23 @@ function App() { async function loadAll() { const timezoneOffset = new Date().getTimezoneOffset(); - const [overviewData, usersData, channelsData, modelsData, logsData] = await Promise.all([ + const [overviewData, usersData, channelsData, modelsData, logsData, groupsData] = await Promise.all([ fetchJson(`/api/overview?timezoneOffset=${timezoneOffset}`), fetchJson<{ users: User[] }>("/api/users"), fetchJson<{ channels: Channel[] }>("/api/channels"), fetchJson<{ models: ModelItem[] }>("/api/models"), - fetchJson<{ logs: RequestLog[] }>("/api/logs") + fetchJson<{ logs: RequestLog[] }>("/api/logs"), + fetchJson<{ groups: UserGroup[] }>("/api/groups") ]); const nextUsers = arrayOf(usersData.users); const nextChannels = arrayOf(channelsData.channels).map(normalizeChannel); const nextModels = arrayOf(modelsData.models).map(normalizeModel); const nextLogs = arrayOf(logsData.logs); + const nextGroups = arrayOf(groupsData.groups); setOverview(overviewData); setUsers(nextUsers); setChannels(nextChannels); + setGroups(nextGroups); setModels(nextModels); setLogs(nextLogs); setSelectedUserId((current) => { @@ -939,6 +953,36 @@ function App() { window.setTimeout(() => setToast(""), 1800); } + async function createGroup(payload: { name: string; description: string }) { + const data = await fetchJson<{ group: UserGroup }>("/api/groups", { + method: "POST", + body: JSON.stringify(payload) + }); + setGroups((current) => [...current, data.group]); + setToast("分组已创建"); + window.setTimeout(() => setToast(""), 1800); + } + + async function updateGroup(id: string, patch: Partial) { + const data = await fetchJson<{ group: UserGroup }>(`/api/groups/${id}`, { + method: "PATCH", + body: JSON.stringify(patch) + }); + setGroups((current) => current.map((group) => (group.id === id ? data.group : group))); + setToast("分组已更新"); + window.setTimeout(() => setToast(""), 1800); + } + + async function deleteGroup(id: string) { + const group = groups.find((item) => item.id === id); + if (!window.confirm(`删除分组「${group?.name || id}」?删除后渠道的可见范围会移除该分组。`)) return; + await fetchJson<{ deleted: boolean }>(`/api/groups/${id}`, { method: "DELETE" }); + setGroups((current) => current.filter((item) => item.id !== id)); + setChannels((current) => current.map((channel) => ({ ...channel, allowedGroupIds: channel.allowedGroupIds.filter((groupId) => groupId !== id) }))); + setToast("分组已删除"); + window.setTimeout(() => setToast(""), 1800); + } + async function syncChannelModels(id: string, models?: string[]) { const explicit = arrayOf(models).map((model) => model.trim()).filter(Boolean); const data = await fetchJson(`/api/channels/${id}/sync-models`, { @@ -1274,6 +1318,7 @@ function App() { onUpdate={updateUser} onBulkUpdate={bulkUpdateUsers} onCreateKey={createAPIKeyForUser} + groups={groups} onOpenRegistration={() => { setActive("settings"); setToast("在账号与注册里开放注册,用户即可自助创建账号"); @@ -1281,10 +1326,11 @@ function App() { }} /> )} + {active === "groups" && } {active === "keys" && } {active === "models" && } {active === "drawing" && } - {active === "channels" && } + {active === "channels" && } {active === "logs" && } {active === "settings" && } @@ -1607,6 +1653,17 @@ function AccountHome({ } } + async function deleteKey(id: string) { + if (!window.confirm("删除这个 API Key?使用它的请求会立即失效。")) return; + try { + await fetchJson<{ deleted: boolean }>(`/api/account/api-keys/${id}`, { method: "DELETE" }); + setData((current) => current ? { ...current, apiKeys: current.apiKeys.filter((key) => key.id !== id) } : current); + setMessage("密钥已删除"); + } catch (error) { + setMessage(error instanceof Error ? error.message : "删除密钥失败"); + } + } + async function logout() { await fetchJson("/api/auth/logout", { method: "POST" }); goHome(); @@ -1690,6 +1747,9 @@ function AccountHome({ {key.prefix}… {statusLabel(key.status)} + ))} {arrayOf(data?.apiKeys).length === 0 &&
还没有 API 密钥
} @@ -1944,6 +2004,7 @@ function UsersView({ onUpdate, onBulkUpdate, onCreateKey, + groups, onOpenRegistration }: { users: User[]; @@ -1958,6 +2019,7 @@ function UsersView({ options?: { value?: string; amount?: number; reason?: string } ) => Promise; onCreateKey: (id: string) => void; + groups: UserGroup[]; onOpenRegistration: () => void; }) { const pageSize = 25; @@ -2186,6 +2248,22 @@ function UsersView({ +
+ + 分组决定该用户可路由到哪些渠道;未分组用户只能使用未限制分组的渠道。 +
+
调整余额 @@ -3256,6 +3334,7 @@ function accountNeedsAttention(account: OpenAIAccount) { function ChannelsView({ channels, + groups, onUpdate, onCreate, onImport, @@ -3264,6 +3343,7 @@ function ChannelsView({ onCheck }: { channels: Channel[]; + groups: UserGroup[]; onUpdate: (id: string, patch: ChannelPatch) => Promise; onCreate: (channel: ChannelCreate) => Promise; onImport: (channelId: string, file: File) => Promise; @@ -3406,7 +3486,7 @@ function ChannelsView({ )}
{channels.map((channel) => ( - + ))} {channels.length === 0 && }
@@ -3416,6 +3496,7 @@ function ChannelsView({ function ChannelEditor({ channel, + groups, onUpdate, onImport, onDelete, @@ -3423,6 +3504,7 @@ function ChannelEditor({ onCheck }: { channel: Channel; + groups: UserGroup[]; onUpdate: (id: string, patch: ChannelPatch) => Promise; onImport: (channelId: string, file: File) => Promise; onDelete: (id: string) => void; @@ -3433,6 +3515,7 @@ function ChannelEditor({ const [provider, setProvider] = useState(channel.provider); const [streamMode, setStreamMode] = useState(channel.streamMode || "auto"); const [baseUrl, setBaseUrl] = useState(channel.baseUrl); + const [allowedGroupIds, setAllowedGroupIds] = useState(arrayOf(channel.allowedGroupIds)); const [models, setModels] = useState(arrayOf(channel.models).join(", ")); const [modelSource, setModelSource] = useState<"saved" | "template" | "manual" | "synced">("saved"); const [inputPrice, setInputPrice] = useState(String(channel.inputPricePer1K || 0)); @@ -3464,6 +3547,7 @@ function ChannelEditor({ setProvider(channel.provider); setStreamMode(channel.streamMode || "auto"); setBaseUrl(channel.baseUrl); + setAllowedGroupIds(arrayOf(channel.allowedGroupIds)); setModels(arrayOf(channel.models).join(", ")); setModelSource("saved"); setInputPrice(String(channel.inputPricePer1K || 0)); @@ -3499,7 +3583,8 @@ function ChannelEditor({ models: models .split(",") .map((model) => model.trim()) - .filter(Boolean) + .filter(Boolean), + allowedGroupIds }; Object.assign(patch, upstreamKeyFields(upstreamApiKey)); return patch; @@ -3695,6 +3780,34 @@ function ChannelEditor({ 定价可先留空;接入是否可用优先看渠道检测和模型同步结果。
+
+
+ 可见分组 + {allowedGroupIds.length ? `已选择 ${allowedGroupIds.length} 个分组` : "全部用户可用"} +
+ {groups.length === 0 ? ( +

还没有用户分组。去「分组」页创建后,可在这里把渠道限制为只对特定分组开放。

+ ) : ( +
+ {groups.map((group) => { + const selected = allowedGroupIds.includes(group.id); + return ( + + ); + })} +
+ )} +
+
+
+ +

分组用于控制渠道对用户的可见范围:把用户归入分组,并在渠道上勾选「可见分组」即可限制访问。未分组的用户只能使用未限制分组的渠道。

+ + + {groups.length === 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ + ); +} + +function GroupRow({ + group, + onUpdate, + onDelete +}: { + group: UserGroup; + onUpdate: (id: string, patch: Partial) => Promise; + onDelete: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [name, setName] = useState(group.name); + const [description, setDescription] = useState(group.description); + const [busy, setBusy] = useState(false); + + async function save() { + setBusy(true); + try { + await onUpdate(group.id, { name: name.trim(), description: description.trim() }); + setEditing(false); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ {editing ? ( +
+ setName(event.target.value)} placeholder="分组名称" /> + setDescription(event.target.value)} placeholder="说明" /> +
+ ) : ( + <> + {group.name} + {group.description && {group.description}} + 创建于 {formatDate(group.createdAt)} + + )} +
+
+ {editing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ ); +} + function Panel({ title, children }: { title: string; children: React.ReactNode }) { return (
diff --git a/src/styles.css b/src/styles.css index bde60cb..92c78be 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6366,3 +6366,112 @@ input[type="radio"]:active { border-color: var(--glass-border); box-shadow: var(--glass-shadow); } + +/* User groups + channel visibility scoping */ +.group-assign-row { + display: grid; + gap: 6px; + margin-top: 4px; + padding: 12px 14px; + background: var(--group); + border: 1px solid var(--hairline); + border-radius: 14px; +} + +.group-assign-row label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.group-assign-row select { + height: 38px; + padding: 0 10px; + color: var(--text); + background: var(--surface-solid); + border: 1px solid var(--hairline); + border-radius: 8px; + outline: none; +} + +.group-assign-row select:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 18%, transparent); +} + +.group-assign-row small { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.channel-group-field { + display: grid; + gap: 8px; + padding: 12px 14px; + background: var(--group); + border: 1px solid var(--hairline); + border-radius: 14px; +} + +.channel-group-empty { + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} + +.channel-group-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.channel-group-options button { + display: grid; + gap: 2px; + min-width: 0; + padding: 8px 12px; + color: var(--text); + text-align: left; + background: var(--surface-solid); + border: 1px solid var(--hairline); + border-radius: 12px; + cursor: pointer; +} + +.channel-group-options button small { + color: var(--muted); +} + +.channel-group-options button.selected { + border-color: color-mix(in srgb, var(--blue) 46%, var(--hairline)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--blue) 28%, transparent); +} + +.app-shell[data-theme="dark"] .group-assign-row select, +.app-shell[data-theme="dark"] .channel-group-options button, +.app-shell[data-theme="dark"] .group-edit-fields input { + background: rgba(118, 118, 128, 0.12); + border-color: transparent; +} + +.group-edit-fields { + display: grid; + gap: 8px; +} + +.group-edit-fields input { + height: 38px; + padding: 0 10px; + color: var(--text); + background: var(--group); + border: 1px solid var(--hairline); + border-radius: 8px; + outline: none; +} + +.group-edit-fields input:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 18%, transparent); +}