diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md new file mode 100644 index 00000000..541bf1fa --- /dev/null +++ b/DEV_GUIDE.md @@ -0,0 +1,323 @@ +# sub2api 项目开发指南 + +> 本文档记录项目环境配置、常见坑点和注意事项,供 Claude Code 和团队成员参考。 + +## 一、项目基本信息 + +| 项目 | 说明 | +|------|------| +| **上游仓库** | Wei-Shaw/sub2api | +| **Fork 仓库** | bayma888/sub2api-bmai | +| **技术栈** | Go 后端 (Ent ORM + Gin) + Vue3 前端 (pnpm) | +| **数据库** | PostgreSQL 16 + Redis | +| **包管理** | 后端: go modules, 前端: **pnpm**(不是 npm) | + +## 二、本地环境配置 + +### PostgreSQL 16 (Windows 服务) + +| 配置项 | 值 | +|--------|-----| +| 端口 | 5432 | +| psql 路径 | `C:\Program Files\PostgreSQL\16\bin\psql.exe` | +| pg_hba.conf | `C:\Program Files\PostgreSQL\16\data\pg_hba.conf` | +| 数据库凭据 | user=`sub2api`, password=`sub2api`, dbname=`sub2api` | +| 超级用户 | user=`postgres`, password=`postgres` | + +### Redis + +| 配置项 | 值 | +|--------|-----| +| 端口 | 6379 | +| 密码 | 无 | + +### 开发工具 + +```bash +# golangci-lint v2.7 +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7 + +# pnpm (前端包管理) +npm install -g pnpm +``` + +## 三、CI/CD 流水线 + +### GitHub Actions Workflows + +| Workflow | 触发条件 | 检查内容 | +|----------|----------|----------| +| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.7 | +| **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit | +| **release.yml** | tag `v*` | 构建发布(PR 不触发) | + +### CI 要求 + +- Go 版本必须是 **1.25.7** +- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml` + +### 本地测试命令 + +```bash +# 后端单元测试 +cd backend && go test -tags=unit ./... + +# 后端集成测试 +cd backend && go test -tags=integration ./... + +# 代码质量检查 +cd backend && golangci-lint run ./... + +# 前端依赖安装(必须用 pnpm) +cd frontend && pnpm install +``` + +## 四、常见坑点 & 解决方案 + +### 坑 1:pnpm-lock.yaml 必须同步提交 + +**问题**:`package.json` 新增依赖后,CI 的 `pnpm install --frozen-lockfile` 失败。 + +**原因**:上游 CI 使用 pnpm,lock 文件不同步会报错。 + +**解决**: +```bash +cd frontend +pnpm install # 更新 pnpm-lock.yaml +git add pnpm-lock.yaml +git commit -m "chore: update pnpm-lock.yaml" +``` + +--- + +### 坑 2:npm 和 pnpm 的 node_modules 冲突 + +**问题**:之前用 npm 装过 `node_modules`,pnpm install 报 `EPERM` 错误。 + +**解决**: +```bash +cd frontend +rm -rf node_modules # 或 PowerShell: Remove-Item -Recurse -Force node_modules +pnpm install +``` + +--- + +### 坑 3:PowerShell 中 bcrypt hash 的 `$` 被转义 + +**问题**:bcrypt hash 格式如 `$2a$10$xxx...`,PowerShell 把 `$2a` 当变量解析,导致数据丢失。 + +**解决**:将 SQL 写入文件,用 `psql -f` 执行: +```bash +# 错误示范(PowerShell 会吃掉 $) +psql -c "INSERT INTO users ... VALUES ('$2a$10$...')" + +# 正确做法 +echo "INSERT INTO users ... VALUES ('\$2a\$10\$...')" > temp.sql +psql -U sub2api -h 127.0.0.1 -d sub2api -f temp.sql +``` + +--- + +### 坑 4:psql 不支持中文路径 + +**问题**:`psql -f "D:\中文路径\file.sql"` 报错找不到文件。 + +**解决**:复制到纯英文路径再执行: +```bash +cp "D:\中文路径\file.sql" "C:\temp.sql" +psql -f "C:\temp.sql" +``` + +--- + +### 坑 5:PostgreSQL 密码重置流程 + +**场景**:忘记 PostgreSQL 密码。 + +**步骤**: +1. 修改 `C:\Program Files\PostgreSQL\16\data\pg_hba.conf` + ``` + # 将 scram-sha-256 改为 trust + host all all 127.0.0.1/32 trust + ``` +2. 重启 PostgreSQL 服务 + ```powershell + Restart-Service postgresql-x64-16 + ``` +3. 无密码登录并重置 + ```bash + psql -U postgres -h 127.0.0.1 + ALTER USER sub2api WITH PASSWORD 'sub2api'; + ALTER USER postgres WITH PASSWORD 'postgres'; + ``` +4. 改回 `scram-sha-256` 并重启 + +--- + +### 坑 6:Go interface 新增方法后 test stub 必须补全 + +**问题**:给 interface 新增方法后,编译报错 `does not implement interface (missing method XXX)`。 + +**原因**:所有测试文件中实现该 interface 的 stub/mock 都必须补上新方法。 + +**解决**: +```bash +# 搜索所有实现该 interface 的 struct +cd backend +grep -r "type.*Stub.*struct" internal/ +grep -r "type.*Mock.*struct" internal/ + +# 逐一补全新方法 +``` + +--- + +### 坑 7:Windows 上 psql 连 localhost 的 IPv6 问题 + +**问题**:psql 连 `localhost` 先尝试 IPv6 (::1),可能报错后再回退 IPv4。 + +**建议**:直接用 `127.0.0.1` 代替 `localhost`。 + +--- + +### 坑 8:Windows 没有 make 命令 + +**问题**:CI 里用 `make test-unit`,本地 Windows 没有 make。 + +**解决**:直接用 Makefile 里的原始命令: +```bash +# 代替 make test-unit +go test -tags=unit ./... + +# 代替 make test-integration +go test -tags=integration ./... +``` + +--- + +### 坑 9:Ent Schema 修改后必须重新生成 + +**问题**:修改 `ent/schema/*.go` 后,代码不生效。 + +**解决**: +```bash +cd backend +go generate ./ent # 重新生成 ent 代码 +git add ent/ # 生成的文件也要提交 +``` + +--- + +### 坑 10:PR 提交前检查清单 + +提交 PR 前务必本地验证: + +- [ ] `go test -tags=unit ./...` 通过 +- [ ] `go test -tags=integration ./...` 通过 +- [ ] `golangci-lint run ./...` 无新增问题 +- [ ] `pnpm-lock.yaml` 已同步(如果改了 package.json) +- [ ] 所有 test stub 补全新接口方法(如果改了 interface) +- [ ] Ent 生成的代码已提交(如果改了 schema) + +## 五、常用命令速查 + +### 数据库操作 + +```bash +# 连接数据库 +psql -U sub2api -h 127.0.0.1 -d sub2api + +# 查看所有用户 +psql -U postgres -h 127.0.0.1 -c "\du" + +# 查看所有数据库 +psql -U postgres -h 127.0.0.1 -c "\l" + +# 执行 SQL 文件 +psql -U sub2api -h 127.0.0.1 -d sub2api -f migration.sql +``` + +### Git 操作 + +```bash +# 同步上游 +git fetch upstream +git checkout main +git merge upstream/main +git push origin main + +# 创建功能分支 +git checkout -b feature/xxx + +# Rebase 到最新 main +git fetch upstream +git rebase upstream/main +``` + +### 前端操作 + +```bash +# 安装依赖(必须用 pnpm) +cd frontend +pnpm install + +# 开发服务器 +pnpm dev + +# 构建 +pnpm build +``` + +### 后端操作 + +```bash +# 运行服务器 +cd backend +go run ./cmd/server/ + +# 生成 Ent 代码 +go generate ./ent + +# 运行测试 +go test -tags=unit ./... +go test -tags=integration ./... + +# Lint 检查 +golangci-lint run ./... +``` + +## 六、项目结构速览 + +``` +sub2api-bmai/ +├── backend/ +│ ├── cmd/server/ # 主程序入口 +│ ├── ent/ # Ent ORM 生成代码 +│ │ └── schema/ # 数据库 Schema 定义 +│ ├── internal/ +│ │ ├── handler/ # HTTP 处理器 +│ │ ├── service/ # 业务逻辑 +│ │ ├── repository/ # 数据访问层 +│ │ └── server/ # 服务器配置 +│ ├── migrations/ # 数据库迁移脚本 +│ └── config.yaml # 配置文件 +├── frontend/ +│ ├── src/ +│ │ ├── api/ # API 调用 +│ │ ├── components/ # Vue 组件 +│ │ ├── views/ # 页面视图 +│ │ ├── types/ # TypeScript 类型 +│ │ └── i18n/ # 国际化 +│ ├── package.json # 依赖配置 +│ └── pnpm-lock.yaml # pnpm 锁文件(必须提交) +└── .claude/ + └── CLAUDE.md # 本文档 +``` + +## 七、参考资源 + +- [上游仓库](https://github.com/Wei-Shaw/sub2api) +- [Ent 文档](https://entgo.io/docs/getting-started) +- [Vue3 文档](https://vuejs.org/) +- [pnpm 文档](https://pnpm.io/) diff --git a/backend/ent/group.go b/backend/ent/group.go index 1eb05e0e..3c8d68b5 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -66,6 +66,8 @@ type Group struct { McpXMLInject bool `json:"mcp_xml_inject,omitempty"` // 支持的模型系列:claude, gemini_text, gemini_image SupportedModelScopes []string `json:"supported_model_scopes,omitempty"` + // 分组显示排序,数值越小越靠前 + SortOrder int `json:"sort_order,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -178,7 +180,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: values[i] = new(sql.NullFloat64) - case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest: + case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder: values[i] = new(sql.NullInt64) case group.FieldName, group.FieldDescription, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType: values[i] = new(sql.NullString) @@ -363,6 +365,12 @@ func (_m *Group) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field supported_model_scopes: %w", err) } } + case group.FieldSortOrder: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field sort_order", values[i]) + } else if value.Valid { + _m.SortOrder = int(value.Int64) + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -530,6 +538,9 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("supported_model_scopes=") builder.WriteString(fmt.Sprintf("%v", _m.SupportedModelScopes)) + builder.WriteString(", ") + builder.WriteString("sort_order=") + builder.WriteString(fmt.Sprintf("%v", _m.SortOrder)) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 278b2daf..31c67756 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -63,6 +63,8 @@ const ( FieldMcpXMLInject = "mcp_xml_inject" // FieldSupportedModelScopes holds the string denoting the supported_model_scopes field in the database. FieldSupportedModelScopes = "supported_model_scopes" + // FieldSortOrder holds the string denoting the sort_order field in the database. + FieldSortOrder = "sort_order" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -162,6 +164,7 @@ var Columns = []string{ FieldModelRoutingEnabled, FieldMcpXMLInject, FieldSupportedModelScopes, + FieldSortOrder, } var ( @@ -225,6 +228,8 @@ var ( DefaultMcpXMLInject bool // DefaultSupportedModelScopes holds the default value on creation for the "supported_model_scopes" field. DefaultSupportedModelScopes []string + // DefaultSortOrder holds the default value on creation for the "sort_order" field. + DefaultSortOrder int ) // OrderOption defines the ordering options for the Group queries. @@ -345,6 +350,11 @@ func ByMcpXMLInject(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldMcpXMLInject, opts...).ToFunc() } +// BySortOrder orders the results by the sort_order field. +func BySortOrder(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSortOrder, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index b6fa2c33..cd5197c9 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -165,6 +165,11 @@ func McpXMLInject(v bool) predicate.Group { return predicate.Group(sql.FieldEQ(FieldMcpXMLInject, v)) } +// SortOrder applies equality check predicate on the "sort_order" field. It's identical to SortOrderEQ. +func SortOrder(v int) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSortOrder, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Group { return predicate.Group(sql.FieldEQ(FieldCreatedAt, v)) @@ -1160,6 +1165,46 @@ func McpXMLInjectNEQ(v bool) predicate.Group { return predicate.Group(sql.FieldNEQ(FieldMcpXMLInject, v)) } +// SortOrderEQ applies the EQ predicate on the "sort_order" field. +func SortOrderEQ(v int) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldSortOrder, v)) +} + +// SortOrderNEQ applies the NEQ predicate on the "sort_order" field. +func SortOrderNEQ(v int) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldSortOrder, v)) +} + +// SortOrderIn applies the In predicate on the "sort_order" field. +func SortOrderIn(vs ...int) predicate.Group { + return predicate.Group(sql.FieldIn(FieldSortOrder, vs...)) +} + +// SortOrderNotIn applies the NotIn predicate on the "sort_order" field. +func SortOrderNotIn(vs ...int) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldSortOrder, vs...)) +} + +// SortOrderGT applies the GT predicate on the "sort_order" field. +func SortOrderGT(v int) predicate.Group { + return predicate.Group(sql.FieldGT(FieldSortOrder, v)) +} + +// SortOrderGTE applies the GTE predicate on the "sort_order" field. +func SortOrderGTE(v int) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldSortOrder, v)) +} + +// SortOrderLT applies the LT predicate on the "sort_order" field. +func SortOrderLT(v int) predicate.Group { + return predicate.Group(sql.FieldLT(FieldSortOrder, v)) +} + +// SortOrderLTE applies the LTE predicate on the "sort_order" field. +func SortOrderLTE(v int) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldSortOrder, v)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.Group { return predicate.Group(func(s *sql.Selector) { diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 9d845b61..707600a7 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -340,6 +340,20 @@ func (_c *GroupCreate) SetSupportedModelScopes(v []string) *GroupCreate { return _c } +// SetSortOrder sets the "sort_order" field. +func (_c *GroupCreate) SetSortOrder(v int) *GroupCreate { + _c.mutation.SetSortOrder(v) + return _c +} + +// SetNillableSortOrder sets the "sort_order" field if the given value is not nil. +func (_c *GroupCreate) SetNillableSortOrder(v *int) *GroupCreate { + if v != nil { + _c.SetSortOrder(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -521,6 +535,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultSupportedModelScopes _c.mutation.SetSupportedModelScopes(v) } + if _, ok := _c.mutation.SortOrder(); !ok { + v := group.DefaultSortOrder + _c.mutation.SetSortOrder(v) + } return nil } @@ -585,6 +603,9 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.SupportedModelScopes(); !ok { return &ValidationError{Name: "supported_model_scopes", err: errors.New(`ent: missing required field "Group.supported_model_scopes"`)} } + if _, ok := _c.mutation.SortOrder(); !ok { + return &ValidationError{Name: "sort_order", err: errors.New(`ent: missing required field "Group.sort_order"`)} + } return nil } @@ -708,6 +729,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldSupportedModelScopes, field.TypeJSON, value) _node.SupportedModelScopes = value } + if value, ok := _c.mutation.SortOrder(); ok { + _spec.SetField(group.FieldSortOrder, field.TypeInt, value) + _node.SortOrder = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1266,6 +1291,24 @@ func (u *GroupUpsert) UpdateSupportedModelScopes() *GroupUpsert { return u } +// SetSortOrder sets the "sort_order" field. +func (u *GroupUpsert) SetSortOrder(v int) *GroupUpsert { + u.Set(group.FieldSortOrder, v) + return u +} + +// UpdateSortOrder sets the "sort_order" field to the value that was provided on create. +func (u *GroupUpsert) UpdateSortOrder() *GroupUpsert { + u.SetExcluded(group.FieldSortOrder) + return u +} + +// AddSortOrder adds v to the "sort_order" field. +func (u *GroupUpsert) AddSortOrder(v int) *GroupUpsert { + u.Add(group.FieldSortOrder, v) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -1780,6 +1823,27 @@ func (u *GroupUpsertOne) UpdateSupportedModelScopes() *GroupUpsertOne { }) } +// SetSortOrder sets the "sort_order" field. +func (u *GroupUpsertOne) SetSortOrder(v int) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetSortOrder(v) + }) +} + +// AddSortOrder adds v to the "sort_order" field. +func (u *GroupUpsertOne) AddSortOrder(v int) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddSortOrder(v) + }) +} + +// UpdateSortOrder sets the "sort_order" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateSortOrder() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateSortOrder() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -2460,6 +2524,27 @@ func (u *GroupUpsertBulk) UpdateSupportedModelScopes() *GroupUpsertBulk { }) } +// SetSortOrder sets the "sort_order" field. +func (u *GroupUpsertBulk) SetSortOrder(v int) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetSortOrder(v) + }) +} + +// AddSortOrder adds v to the "sort_order" field. +func (u *GroupUpsertBulk) AddSortOrder(v int) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddSortOrder(v) + }) +} + +// UpdateSortOrder sets the "sort_order" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateSortOrder() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateSortOrder() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 9e7246ea..393fd304 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -475,6 +475,27 @@ func (_u *GroupUpdate) AppendSupportedModelScopes(v []string) *GroupUpdate { return _u } +// SetSortOrder sets the "sort_order" field. +func (_u *GroupUpdate) SetSortOrder(v int) *GroupUpdate { + _u.mutation.ResetSortOrder() + _u.mutation.SetSortOrder(v) + return _u +} + +// SetNillableSortOrder sets the "sort_order" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableSortOrder(v *int) *GroupUpdate { + if v != nil { + _u.SetSortOrder(*v) + } + return _u +} + +// AddSortOrder adds value to the "sort_order" field. +func (_u *GroupUpdate) AddSortOrder(v int) *GroupUpdate { + _u.mutation.AddSortOrder(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -912,6 +933,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { sqljson.Append(u, group.FieldSupportedModelScopes, value) }) } + if value, ok := _u.mutation.SortOrder(); ok { + _spec.SetField(group.FieldSortOrder, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedSortOrder(); ok { + _spec.AddField(group.FieldSortOrder, field.TypeInt, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1666,6 +1693,27 @@ func (_u *GroupUpdateOne) AppendSupportedModelScopes(v []string) *GroupUpdateOne return _u } +// SetSortOrder sets the "sort_order" field. +func (_u *GroupUpdateOne) SetSortOrder(v int) *GroupUpdateOne { + _u.mutation.ResetSortOrder() + _u.mutation.SetSortOrder(v) + return _u +} + +// SetNillableSortOrder sets the "sort_order" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableSortOrder(v *int) *GroupUpdateOne { + if v != nil { + _u.SetSortOrder(*v) + } + return _u +} + +// AddSortOrder adds value to the "sort_order" field. +func (_u *GroupUpdateOne) AddSortOrder(v int) *GroupUpdateOne { + _u.mutation.AddSortOrder(v) + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -2133,6 +2181,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) sqljson.Append(u, group.FieldSupportedModelScopes, value) }) } + if value, ok := _u.mutation.SortOrder(); ok { + _spec.SetField(group.FieldSortOrder, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedSortOrder(); ok { + _spec.AddField(group.FieldSortOrder, field.TypeInt, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index f9e90d73..cfd4a72b 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -372,6 +372,7 @@ var ( {Name: "model_routing_enabled", Type: field.TypeBool, Default: false}, {Name: "mcp_xml_inject", Type: field.TypeBool, Default: true}, {Name: "supported_model_scopes", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "sort_order", Type: field.TypeInt, Default: 0}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ @@ -404,6 +405,11 @@ var ( Unique: false, Columns: []*schema.Column{GroupsColumns[3]}, }, + { + Name: "group_sort_order", + Unique: false, + Columns: []*schema.Column{GroupsColumns[25]}, + }, }, } // PromoCodesColumns holds the columns for the "promo_codes" table. diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 5c182dea..969d9357 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -7059,6 +7059,8 @@ type GroupMutation struct { mcp_xml_inject *bool supported_model_scopes *[]string appendsupported_model_scopes []string + sort_order *int + addsort_order *int clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -8411,6 +8413,62 @@ func (m *GroupMutation) ResetSupportedModelScopes() { m.appendsupported_model_scopes = nil } +// SetSortOrder sets the "sort_order" field. +func (m *GroupMutation) SetSortOrder(i int) { + m.sort_order = &i + m.addsort_order = nil +} + +// SortOrder returns the value of the "sort_order" field in the mutation. +func (m *GroupMutation) SortOrder() (r int, exists bool) { + v := m.sort_order + if v == nil { + return + } + return *v, true +} + +// OldSortOrder returns the old "sort_order" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldSortOrder(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSortOrder is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSortOrder requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSortOrder: %w", err) + } + return oldValue.SortOrder, nil +} + +// AddSortOrder adds i to the "sort_order" field. +func (m *GroupMutation) AddSortOrder(i int) { + if m.addsort_order != nil { + *m.addsort_order += i + } else { + m.addsort_order = &i + } +} + +// AddedSortOrder returns the value that was added to the "sort_order" field in this mutation. +func (m *GroupMutation) AddedSortOrder() (r int, exists bool) { + v := m.addsort_order + if v == nil { + return + } + return *v, true +} + +// ResetSortOrder resets all changes to the "sort_order" field. +func (m *GroupMutation) ResetSortOrder() { + m.sort_order = nil + m.addsort_order = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -8769,7 +8827,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 24) + fields := make([]string, 0, 25) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -8842,6 +8900,9 @@ func (m *GroupMutation) Fields() []string { if m.supported_model_scopes != nil { fields = append(fields, group.FieldSupportedModelScopes) } + if m.sort_order != nil { + fields = append(fields, group.FieldSortOrder) + } return fields } @@ -8898,6 +8959,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.McpXMLInject() case group.FieldSupportedModelScopes: return m.SupportedModelScopes() + case group.FieldSortOrder: + return m.SortOrder() } return nil, false } @@ -8955,6 +9018,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldMcpXMLInject(ctx) case group.FieldSupportedModelScopes: return m.OldSupportedModelScopes(ctx) + case group.FieldSortOrder: + return m.OldSortOrder(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -9132,6 +9197,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetSupportedModelScopes(v) return nil + case group.FieldSortOrder: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSortOrder(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -9170,6 +9242,9 @@ func (m *GroupMutation) AddedFields() []string { if m.addfallback_group_id_on_invalid_request != nil { fields = append(fields, group.FieldFallbackGroupIDOnInvalidRequest) } + if m.addsort_order != nil { + fields = append(fields, group.FieldSortOrder) + } return fields } @@ -9198,6 +9273,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedFallbackGroupID() case group.FieldFallbackGroupIDOnInvalidRequest: return m.AddedFallbackGroupIDOnInvalidRequest() + case group.FieldSortOrder: + return m.AddedSortOrder() } return nil, false } @@ -9277,6 +9354,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddFallbackGroupIDOnInvalidRequest(v) return nil + case group.FieldSortOrder: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddSortOrder(v) + return nil } return fmt.Errorf("unknown Group numeric field %s", name) } @@ -9445,6 +9529,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldSupportedModelScopes: m.ResetSupportedModelScopes() return nil + case group.FieldSortOrder: + m.ResetSortOrder() + return nil } return fmt.Errorf("unknown Group field %s", name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 4b3c1a4f..e5c34929 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -409,6 +409,10 @@ func init() { groupDescSupportedModelScopes := groupFields[20].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) + // groupDescSortOrder is the schema descriptor for sort_order field. + groupDescSortOrder := groupFields[21].Descriptor() + // group.DefaultSortOrder holds the default value on creation for the sort_order field. + group.DefaultSortOrder = groupDescSortOrder.Default.(int) promocodeFields := schema.PromoCode{}.Fields() _ = promocodeFields // promocodeDescCode is the schema descriptor for code field. diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 8a3c1a90..c36ca770 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -121,6 +121,11 @@ func (Group) Fields() []ent.Field { Default([]string{"claude", "gemini_text", "gemini_image"}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). Comment("支持的模型系列:claude, gemini_text, gemini_image"), + + // 分组排序 (added by migration 052) + field.Int("sort_order"). + Default(0). + Comment("分组显示排序,数值越小越靠前"), } } @@ -149,5 +154,6 @@ func (Group) Indexes() []ent.Index { index.Fields("subscription_type"), index.Fields("is_exclusive"), index.Fields("deleted_at"), + index.Fields("sort_order"), } } diff --git a/backend/go.sum b/backend/go.sum index 171995c7..90470fbc 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -135,6 +135,8 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -170,6 +172,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -203,6 +207,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -230,6 +236,8 @@ github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkr github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -252,6 +260,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 77d288f9..cbbfe942 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -357,5 +357,9 @@ func (s *stubAdminService) GetUserBalanceHistory(ctx context.Context, userID int return s.redeems, int64(len(s.redeems)), 100.0, nil } +func (s *stubAdminService) UpdateGroupSortOrders(ctx context.Context, updates []service.GroupSortOrderUpdate) error { + return nil +} + // Ensure stub implements interface. var _ service.AdminService = (*stubAdminService)(nil) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index d10d678b..7daaf281 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -302,3 +302,36 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { } response.Paginated(c, outKeys, total, page, pageSize) } + +// UpdateSortOrderRequest represents the request to update group sort orders +type UpdateSortOrderRequest struct { + Updates []struct { + ID int64 `json:"id" binding:"required"` + SortOrder int `json:"sort_order"` + } `json:"updates" binding:"required,min=1"` +} + +// UpdateSortOrder handles updating group sort orders +// PUT /api/v1/admin/groups/sort-order +func (h *GroupHandler) UpdateSortOrder(c *gin.Context) { + var req UpdateSortOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + updates := make([]service.GroupSortOrderUpdate, 0, len(req.Updates)) + for _, u := range req.Updates { + updates = append(updates, service.GroupSortOrderUpdate{ + ID: u.ID, + SortOrder: u.SortOrder, + }) + } + + if err := h.adminService.UpdateGroupSortOrders(c.Request.Context(), updates); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Sort order updated successfully"}) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d14ab1d1..2caf6847 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -115,6 +115,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { MCPXMLInject: g.MCPXMLInject, SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, + SortOrder: g.SortOrder, } if len(g.AccountGroups) > 0 { out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups)) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 71bb1ed4..2338eb78 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -98,6 +98,9 @@ type AdminGroup struct { SupportedModelScopes []string `json:"supported_model_scopes"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` AccountCount int64 `json:"account_count,omitempty"` + + // 分组排序 + SortOrder int `json:"sort_order"` } type Account struct { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index c0cfd256..22dfa700 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -485,6 +485,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { ModelRoutingEnabled: g.ModelRoutingEnabled, MCPXMLInject: g.McpXMLInject, SupportedModelScopes: g.SupportedModelScopes, + SortOrder: g.SortOrder, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index d8cec491..4e7a836f 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -191,7 +191,7 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination groups, err := q. Offset(params.Offset()). Limit(params.Limit()). - Order(dbent.Asc(group.FieldID)). + Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)). All(ctx) if err != nil { return nil, nil, err @@ -218,7 +218,7 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, error) { groups, err := r.client.Group.Query(). Where(group.StatusEQ(service.StatusActive)). - Order(dbent.Asc(group.FieldID)). + Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)). All(ctx) if err != nil { return nil, err @@ -245,7 +245,7 @@ func (r *groupRepository) ListActive(ctx context.Context) ([]service.Group, erro func (r *groupRepository) ListActiveByPlatform(ctx context.Context, platform string) ([]service.Group, error) { groups, err := r.client.Group.Query(). Where(group.StatusEQ(service.StatusActive), group.PlatformEQ(platform)). - Order(dbent.Asc(group.FieldID)). + Order(dbent.Asc(group.FieldSortOrder), dbent.Asc(group.FieldID)). All(ctx) if err != nil { return nil, err @@ -497,3 +497,29 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64 return nil } + +// UpdateSortOrders 批量更新分组排序 +func (r *groupRepository) UpdateSortOrders(ctx context.Context, updates []service.GroupSortOrderUpdate) error { + if len(updates) == 0 { + return nil + } + + // 使用事务批量更新 + tx, err := r.client.Tx(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + for _, u := range updates { + if _, err := tx.Group.UpdateOneID(u.ID).SetSortOrder(u.SortOrder).Save(ctx); err != nil { + return translatePersistenceError(err, service.ErrGroupNotFound, nil) + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index efef0452..4ee8a6ee 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -896,6 +896,10 @@ func (stubGroupRepo) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int return nil, errors.New("not implemented") } +func (stubGroupRepo) UpdateSortOrders(ctx context.Context, updates []service.GroupSortOrderUpdate) error { + return nil +} + type stubAccountRepo struct { bulkUpdateIDs []int64 } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 14815262..bd6788b2 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -192,6 +192,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { { groups.GET("", h.Admin.Group.List) groups.GET("/all", h.Admin.Group.GetAll) + groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) groups.PUT("/:id", h.Admin.Group.Update) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 59d7062b..06354e1e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -36,6 +36,7 @@ type AdminService interface { UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) DeleteGroup(ctx context.Context, id int64) error GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) + UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error // Account management ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) @@ -1015,6 +1016,10 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p return keys, result.Total, nil } +func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + return s.groupRepo.UpdateSortOrders(ctx, updates) +} + // Account management implementations func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index c775749d..60fa3d77 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -172,6 +172,10 @@ func (s *groupRepoStub) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs [] panic("unexpected GetAccountIDsByGroupIDs call") } +func (s *groupRepoStub) UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + return nil +} + type proxyRepoStub struct { deleteErr error countErr error diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index d921a086..ef77a980 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -116,6 +116,10 @@ func (s *groupRepoStubForAdmin) GetAccountIDsByGroupIDs(_ context.Context, _ []i panic("unexpected GetAccountIDsByGroupIDs call") } +func (s *groupRepoStubForAdmin) UpdateSortOrders(_ context.Context, _ []GroupSortOrderUpdate) error { + return nil +} + // TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递 func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { repo := &groupRepoStubForAdmin{} @@ -395,6 +399,10 @@ func (s *groupRepoStubForFallbackCycle) GetAccountIDsByGroupIDs(_ context.Contex panic("unexpected GetAccountIDsByGroupIDs call") } +func (s *groupRepoStubForFallbackCycle) UpdateSortOrders(_ context.Context, _ []GroupSortOrderUpdate) error { + return nil +} + type groupRepoStubForInvalidRequestFallback struct { groups map[int64]*Group created *Group @@ -466,6 +474,10 @@ func (s *groupRepoStubForInvalidRequestFallback) BindAccountsToGroup(_ context.C panic("unexpected BindAccountsToGroup call") } +func (s *groupRepoStubForInvalidRequestFallback) UpdateSortOrders(_ context.Context, _ []GroupSortOrderUpdate) error { + return nil +} + func TestAdminService_CreateGroup_InvalidRequestFallbackRejectsUnsupportedPlatform(t *testing.T) { fallbackID := int64(10) repo := &groupRepoStubForInvalidRequestFallback{ diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index d9c852e0..bb0c97e8 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -298,6 +298,10 @@ func (m *mockGroupRepoForGateway) GetAccountIDsByGroupIDs(ctx context.Context, g return nil, nil } +func (m *mockGroupRepoForGateway) UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 0c54dc39..45686d80 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -226,6 +226,10 @@ func (m *mockGroupRepoForGemini) GetAccountIDsByGroupIDs(ctx context.Context, gr return nil, nil } +func (m *mockGroupRepoForGemini) UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + return nil +} + var _ GroupRepository = (*mockGroupRepoForGemini)(nil) // mockGatewayCacheForGemini Gemini 测试用的 cache mock diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 1302047a..e9423ddb 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -45,6 +45,9 @@ type Group struct { // 可选值: claude, gemini_text, gemini_image SupportedModelScopes []string + // 分组排序 + SortOrder int + CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index a2bf2073..22a67eda 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -33,6 +33,14 @@ type GroupRepository interface { GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) // BindAccountsToGroup 将多个账号绑定到指定分组 BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error + // UpdateSortOrders 批量更新分组排序 + UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error +} + +// GroupSortOrderUpdate 分组排序更新 +type GroupSortOrderUpdate struct { + ID int64 `json:"id"` + SortOrder int `json:"sort_order"` } // CreateGroupRequest 创建分组请求 diff --git a/backend/migrations/052_add_group_sort_order.sql b/backend/migrations/052_add_group_sort_order.sql new file mode 100644 index 00000000..ee687608 --- /dev/null +++ b/backend/migrations/052_add_group_sort_order.sql @@ -0,0 +1,8 @@ +-- Add sort_order field to groups table for custom ordering +ALTER TABLE groups ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; + +-- Initialize existing groups with sort_order based on their ID +UPDATE groups SET sort_order = id WHERE sort_order = 0; + +-- Create index for efficient sorting +CREATE INDEX IF NOT EXISTS idx_groups_sort_order ON groups(sort_order); diff --git a/frontend/package.json b/frontend/package.json index 38b92708..325eba60 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "qrcode": "^1.5.4", "vue": "^3.4.0", "vue-chartjs": "^5.3.0", + "vue-draggable-plus": "^0.6.1", "vue-i18n": "^9.14.5", "vue-router": "^4.2.5", "xlsx": "^0.18.5" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7dc73325..9af2d7af 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: vue-chartjs: specifier: ^5.3.0 version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.6.3)) + vue-draggable-plus: + specifier: ^0.6.1 + version: 0.6.1(@types/sortablejs@1.15.9) vue-i18n: specifier: ^9.14.5 version: 9.14.5(vue@3.5.26(typescript@5.6.3)) @@ -1254,67 +1257,56 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1515,6 +1507,9 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4298,6 +4293,15 @@ packages: '@vue/composition-api': optional: true + vue-draggable-plus@0.6.1: + resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==} + peerDependencies: + '@types/sortablejs': ^1.15.0 + '@vue/composition-api': '*' + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-eslint-parser@9.4.3: resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -5958,6 +5962,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sortablejs@1.15.9': {} + '@types/trusted-types@2.0.7': {} '@types/unist@2.0.11': {} @@ -9401,6 +9407,10 @@ snapshots: dependencies: vue: 3.5.26(typescript@5.6.3) + vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9): + dependencies: + '@types/sortablejs': 1.15.9 + vue-eslint-parser@9.4.3(eslint@8.57.1): dependencies: debug: 4.4.3 diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 4d2b10ef..3d18ba87 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -153,6 +153,20 @@ export async function getGroupApiKeys( return data } +/** + * Update group sort orders + * @param updates - Array of { id, sort_order } objects + * @returns Success confirmation + */ +export async function updateSortOrder( + updates: Array<{ id: number; sort_order: number }> +): Promise<{ message: string }> { + const { data } = await apiClient.put<{ message: string }>('/admin/groups/sort-order', { + updates + }) + return data +} + export const groupsAPI = { list, getAll, @@ -163,7 +177,8 @@ export const groupsAPI = { delete: deleteGroup, toggleStatus, getStats, - getGroupApiKeys + getGroupApiKeys, + updateSortOrder } export default groupsAPI diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 1f055111..382a35af 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -58,6 +58,7 @@ const icons = { arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18', arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18', arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3', + arrowsUpDown: 'M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5', chevronUp: 'M5 15l7-7 7 7', externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1cc1fc18..22f4f0bb 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1042,6 +1042,10 @@ export default { createGroup: 'Create Group', editGroup: 'Edit Group', deleteGroup: 'Delete Group', + sortOrder: 'Sort', + sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first', + sortOrderUpdated: 'Sort order updated', + failedToUpdateSortOrder: 'Failed to update sort order', allPlatforms: 'All Platforms', allStatus: 'All Status', allGroups: 'All Groups', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c739d8e7..43edc59a 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1099,6 +1099,10 @@ export default { createGroup: '创建分组', editGroup: '编辑分组', deleteGroup: '删除分组', + sortOrder: '排序', + sortOrderHint: '拖拽分组调整显示顺序,排在前面的分组会优先显示', + sortOrderUpdated: '排序已更新', + failedToUpdateSortOrder: '更新排序失败', deleteConfirm: "确定要删除分组 '{name}' 吗?所有关联的 API 密钥将不再属于任何分组。", deleteConfirmSubscription: "确定要删除订阅分组 '{name}' 吗?此操作会让所有绑定此订阅的用户的 API Key 失效,并删除所有相关的订阅记录。此操作无法撤销。", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 03445ed5..e1260c99 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -379,6 +379,9 @@ export interface AdminGroup extends Group { // 分组下账号数量(仅管理员可见) account_count?: number + + // 分组排序 + sort_order: number } export interface ApiKey { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 57cde914..00515ab7 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -52,6 +52,14 @@ > + + + {{ t('admin.groups.sortOrder') }} + + + + + + + {{ t('admin.groups.sortOrderHint') }} + + + + + + + + {{ group.name }} + + + {{ t('admin.groups.platforms.' + group.platform) }} + + + + + #{{ group.id }} + + + + + + + + + {{ t('common.cancel') }} + + + + + + + {{ sortSubmitting ? t('common.saving') : t('common.save') }} + + + + @@ -1476,6 +1570,7 @@ import EmptyState from '@/components/common/EmptyState.vue' import Select from '@/components/common/Select.vue' import PlatformIcon from '@/components/common/PlatformIcon.vue' import Icon from '@/components/icons/Icon.vue' +import { VueDraggable } from 'vue-draggable-plus' const { t } = useI18n() const appStore = useAppStore() @@ -1640,9 +1735,12 @@ let abortController: AbortController | null = null const showCreateModal = ref(false) const showEditModal = ref(false) const showDeleteDialog = ref(false) +const showSortModal = ref(false) const submitting = ref(false) +const sortSubmitting = ref(false) const editingGroup = ref(null) const deletingGroup = ref(null) +const sortableGroups = ref([]) const createForm = reactive({ name: '', @@ -2101,6 +2199,46 @@ const handleClickOutside = (event: MouseEvent) => { } } +// 打开排序弹窗 +const openSortModal = async () => { + try { + // 获取所有分组(不分页) + const allGroups = await adminAPI.groups.getAll() + // 按 sort_order 排序 + sortableGroups.value = [...allGroups].sort((a, b) => a.sort_order - b.sort_order) + showSortModal.value = true + } catch (error) { + appStore.showError(t('admin.groups.failedToLoad')) + console.error('Error loading groups for sorting:', error) + } +} + +// 关闭排序弹窗 +const closeSortModal = () => { + showSortModal.value = false + sortableGroups.value = [] +} + +// 保存排序 +const saveSortOrder = async () => { + sortSubmitting.value = true + try { + const updates = sortableGroups.value.map((g, index) => ({ + id: g.id, + sort_order: index * 10 + })) + await adminAPI.groups.updateSortOrder(updates) + appStore.showSuccess(t('admin.groups.sortOrderUpdated')) + closeSortModal() + loadGroups() + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdateSortOrder')) + console.error('Error updating sort order:', error) + } finally { + sortSubmitting.value = false + } +} + onMounted(() => { loadGroups() document.addEventListener('click', handleClickOutside)
+ {{ t('admin.groups.sortOrderHint') }} +