Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b5fc435
feat(types): add AccountGroup types, tags/groups fields, and UsageSta…
DeliciousBuding May 13, 2026
537e107
feat(apikeys): add inline rename with pencil button
DeliciousBuding May 13, 2026
65dcfc5
feat(api): add account group CRUD, updateAPIKey, and cache:no-store
DeliciousBuding May 13, 2026
8fcaf27
feat(css): update font stack and add code panel typography
DeliciousBuding May 13, 2026
17e98e0
feat(PageHeader): add actionMeta prop for secondary header content
DeliciousBuding May 13, 2026
e713199
feat(ChipInput): add reusable multi-select chip input component
DeliciousBuding May 13, 2026
567592b
feat(proxies): add edit dialog + concurrent test with progress
DeliciousBuding May 13, 2026
6daf41c
feat(Dashboard): replace inline stats with UsageStatsSummary component
DeliciousBuding May 13, 2026
82b2fa3
feat(api): add updateAPIKey and extend updateProxy with url param
DeliciousBuding May 13, 2026
83f9c6e
feat(usage): add configurable request log columns
DeliciousBuding May 13, 2026
ae4cc09
feat(accounts): add tags groups and column controls
DeliciousBuding May 13, 2026
474e4ea
Implement backend account groups and scheduler scope
DeliciousBuding May 13, 2026
54b2b98
Merge branch 'track-b-frontend-core' into rebase-onto-v2.1.3
DeliciousBuding May 13, 2026
8e0a96a
Merge branch 'track-c-frontend-pages' into rebase-onto-v2.1.3
DeliciousBuding May 13, 2026
685a3c9
docs: add tabbed quick start guide
DeliciousBuding May 13, 2026
afa129f
docs: restore integrated documentation page
DeliciousBuding May 13, 2026
8ae8f06
docs: localize docs page and align tabs
DeliciousBuding May 13, 2026
5891485
docs: add client import config tabs
DeliciousBuding May 13, 2026
63e207f
Improve combined docs experience
DeliciousBuding May 13, 2026
dd45c10
Polish docs import and highlighting performance
DeliciousBuding May 13, 2026
7bc87e5
Keep account filters on one row
DeliciousBuding May 13, 2026
0ae576b
Fix column settings popover clipping
DeliciousBuding May 13, 2026
f9b5bf9
Harden account group routing controls
DeliciousBuding May 13, 2026
5b23e67
Complete account group permission management
DeliciousBuding May 13, 2026
28d317c
Complete API key and group management polish
DeliciousBuding May 13, 2026
852a703
Fix review issues in docs and scheduler management
DeliciousBuding May 13, 2026
3217698
Fill dashboard cache rate and first-token stats
DeliciousBuding May 13, 2026
ca475b7
Fix remaining review issues in docs and group scopes
DeliciousBuding May 13, 2026
fb405c5
Harden docs highlighter and try-it guards
DeliciousBuding May 13, 2026
ab123ef
fix: use errors.Is for sql.ErrNoRows checks across codebase
james-6-23 May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions admin/account_groups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
package admin

import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"

"github.com/codex2api/database"
"github.com/gin-gonic/gin"
)

const (
maxAccountGroups = 64
maxAccountGroupNameRuneSize = 80
)

type accountGroupResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
SortOrder int64 `json:"sort_order"`
MemberCount int64 `json:"member_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

func toAccountGroupResponse(g database.AccountGroup) accountGroupResponse {
return accountGroupResponse{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Color: g.Color,
SortOrder: g.SortOrder,
MemberCount: g.MemberCount,
CreatedAt: g.CreatedAt.Format(time.RFC3339),
UpdatedAt: g.UpdatedAt.Format(time.RFC3339),
}
}

func (h *Handler) ListAccountGroups(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
groups, err := h.db.ListAccountGroups(ctx)
if err != nil {
writeInternalError(c, err)
return
}
out := make([]accountGroupResponse, 0, len(groups))
for _, group := range groups {
out = append(out, toAccountGroupResponse(group))
}
c.JSON(http.StatusOK, gin.H{"groups": out})
}

type createAccountGroupReq struct {
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
SortOrder *int64 `json:"sort_order"`
}

func (h *Handler) CreateAccountGroup(c *gin.Context) {
var req createAccountGroupReq
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, http.StatusBadRequest, "请求格式错误")
return
}
name, err := sanitizeAccountGroupName(req.Name)
if err != nil {
writeError(c, http.StatusBadRequest, err.Error())
return
}
description := strings.TrimSpace(req.Description)
if utf8.RuneCountInString(description) > 240 {
writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符")
return
}
color := strings.TrimSpace(req.Color)
if utf8.RuneCountInString(color) > 20 {
writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符")
return
}
sortOrder := int64(0)
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
groups, err := h.db.ListAccountGroups(ctx)
if err != nil {
writeInternalError(c, err)
return
}
if len(groups) >= maxAccountGroups {
writeError(c, http.StatusBadRequest, "分组数量已达上限")
return
}
id, err := h.db.CreateAccountGroup(ctx, name, description, color, sortOrder)
if err != nil {
if errors.Is(err, database.ErrDuplicateAccountGroupName) {
writeError(c, http.StatusConflict, err.Error())
return
}
writeInternalError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "message": "分组已创建"})
}

type updateAccountGroupReq struct {
Name *string `json:"name"`
Description *string `json:"description"`
Color *string `json:"color"`
SortOrder *int64 `json:"sort_order"`
}

func (h *Handler) UpdateAccountGroup(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
writeError(c, http.StatusBadRequest, "无效的分组 ID")
return
}
var req updateAccountGroupReq
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, http.StatusBadRequest, "请求格式错误")
return
}
if req.Name != nil {
name, err := sanitizeAccountGroupName(*req.Name)
if err != nil {
writeError(c, http.StatusBadRequest, err.Error())
return
}
req.Name = &name
}
if req.Description != nil {
desc := strings.TrimSpace(*req.Description)
if utf8.RuneCountInString(desc) > 240 {
writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符")
return
}
req.Description = &desc
}
if req.Color != nil {
color := strings.TrimSpace(*req.Color)
if utf8.RuneCountInString(color) > 20 {
writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符")
return
}
req.Color = &color
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.db.UpdateAccountGroup(ctx, id, req.Name, req.Description, req.Color, req.SortOrder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(c, http.StatusNotFound, "分组不存在")
return
}
if errors.Is(err, database.ErrDuplicateAccountGroupName) {
writeError(c, http.StatusConflict, err.Error())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
}
writeInternalError(c, err)
return
}
writeMessage(c, http.StatusOK, "分组已更新")
}

func (h *Handler) DeleteAccountGroup(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
writeError(c, http.StatusBadRequest, "无效的分组 ID")
return
}
force := strings.EqualFold(c.Query("force"), "true")
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := h.db.DeleteAccountGroup(ctx, id, force); err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(c, http.StatusNotFound, "分组不存在")
return
}
if errors.Is(err, database.ErrAccountGroupNotEmpty) {
writeError(c, http.StatusConflict, err.Error())
return
}
writeInternalError(c, err)
return
}
if h.store != nil {
for _, acc := range h.store.Accounts() {
acc.Mu().RLock()
groups := removeInt64(acc.GroupIDs, id)
acc.Mu().RUnlock()
h.store.ApplyAccountGroups(acc.DBID, groups)
}
}
h.refreshAPIKeyAllowedGroupsAfterGroupDelete(ctx, id)
writeMessage(c, http.StatusOK, "分组已删除")
}

func (h *Handler) refreshAPIKeyAllowedGroupsAfterGroupDelete(ctx context.Context, groupID int64) {
if h == nil || h.db == nil || groupID <= 0 {
return
}
keys, err := h.db.ListAPIKeys(ctx)
if err != nil {
return
}
for _, key := range keys {
if key == nil {
continue
}
if h.store != nil {
h.store.SetAPIKeyAllowedGroups(key.ID, key.AllowedGroupIDs)
}
h.invalidateAPIKeyRuntimeCaches(ctx, key.Key)
}
}

func sanitizeAccountGroupName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", errors.New("分组名称不能为空")
}
if utf8.RuneCountInString(name) > maxAccountGroupNameRuneSize {
return "", errors.New("分组名称长度超过 80 字符")
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return "", errors.New("分组名称包含非法控制字符")
}
}
return name, nil
}

func removeInt64(slice []int64, target int64) []int64 {
out := make([]int64, 0, len(slice))
for _, v := range slice {
if v != target {
out = append(out, v)
}
}
return out
}

func containsInt64(slice []int64, target int64) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}

func dedupeInt64(ids []int64) []int64 {
if len(ids) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
Loading
Loading