[dev] feat: 用户支持多分组(group0,group1) - #15
Conversation
用户分组不再唯一,users.group 存储逗号分隔的分组列表(第一个为主分组): - common: 新增 SplitGroupList/PrimaryGroup/NormalizeGroupList;GetTopupGroupRatio 按列表顺序取第一个配置项 - 计费: GetGroupGroupRatio 多分组用户按列表顺序取第一个命中的专属倍率,无命中回退全局分组倍率 - 可用分组: GetUserUsableGroups 对每个所属分组合并特殊可用规则并保证所属分组可选(令牌/Playground 分组校验随之生效) - 路由: 无令牌分组时使用主分组作为使用分组;relayInfo.TokenGroup 保持单值进入渠道精确匹配 - 模型列表: 多分组用户返回所有所属分组启用模型的并集 - 后台: 用户搜索按"包含该分组"过滤;编辑保存时规范化分组列表并校验长度(单组<=64,总长<=1024) - 列宽: users.group 与 user_subscriptions.prev_user_group 加宽至 varchar(1024)(AutoMigrate 自动迁移) - 前端: 用户编辑抽屉分组改为多选;用户列表分组列渲染多徽标并按分组精确过滤;i18n 补全 7 种语言
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in group authorization, billing, rate limiting, persistence, filtering, and accessibility.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds multi-group user support across billing, authorization, routing, persistence, model visibility, and the admin UI.
Changes:
- Adds shared group parsing, normalization, and ordered group resolution.
- Updates billing, permissions, routing, rate limits, storage, and filtering.
- Adds multi-select editing, badges, translations, and regression tests.
File summaries
| File | Change | Final review note |
|---|---|---|
web/src/lib/group-list.ts |
Frontend group-list parsing | Nit (3 votes): Add tests for whitespace, empty entries, and primary-order behavior. |
web/src/i18n/locales/zh.json |
Multi-group translations | — |
web/src/i18n/locales/zh-TW.json |
Multi-group translations | — |
web/src/i18n/locales/vi.json |
Multi-group translations | — |
web/src/i18n/locales/ru.json |
Multi-group translations | — |
web/src/i18n/locales/ja.json |
Multi-group translations | — |
web/src/i18n/locales/fr.json |
Multi-group translations | — |
web/src/i18n/locales/en.json |
Multi-group translations | — |
web/src/features/users/components/users-mutate-drawer.tsx |
Multi-group user editor | Moderate (2 votes): Forward aria-describedby and aria-invalid through MultiSelect. |
web/src/features/users/components/users-columns.tsx |
Multi-badge group display and filtering | — |
setting/ratio_setting/group_ratio.go |
Ordered group billing ratios | — |
service/task_billing.go |
Asynchronous task billing | Critical (2 votes): Use the full user group list for user-specific ratio lookup. |
service/group.go |
Usable-group union logic | Critical (2 votes): Preserve owned groups from removal by later special rules. |
service/group_multi_test.go |
Multi-group service tests | Nit (1 vote): Restore the original AutoGroups value in cleanup. |
relay/common/relay_info.go |
Effective relay group selection | — |
model/user.go |
Group storage, normalization, and search | Moderate (1 vote): Keep all/null channel sentinels separate from user-group filtering. |
model/user_group_test.go |
User group persistence and filter tests | — |
model/subscription.go |
Wider subscription group snapshot | — |
model/channel.go |
Shared group containment filtering | — |
middleware/model-rate-limit.go |
Primary-group rate-limit handling | Moderate (3 votes): Resolve the primary/using group for the actual rate-limit lookup. |
middleware/distributor.go |
Playground group authorization | — |
middleware/auth.go |
Primary and token-group handling | — |
controller/user.go |
Group validation and normalization | Moderate (1 vote): Align the 64-character group limit with Task.Group’s varchar(50) storage. |
controller/model.go |
Multi-group model union | — |
controller/channel.go |
Group filter usage | — |
common/topup-ratio.go |
Ordered top-up ratio lookup | — |
common/group.go |
Shared group utilities | — |
common/group_test.go |
Group utility tests | — |
Review details
Suppressed comments (3)
controller/user.go:680
- This accepts group names up to 64 characters, but
model.Task.Groupis stillvarchar(50)andNewTaskpersistsrelayInfo.UsingGroupinto that column. A valid 51–64-character group selected for an asynchronous task can therefore be truncated or rejected by MySQL/PostgreSQL. Widen the task column and migrate it, or align this bound with the narrowest consumer.
if len(group) > common.MaxGroupNameLength {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
model/user.go:458
ApplyGroupContainsFilterreusesNormalizeChannelGroupFilter, which treats the literal valuesallandnullas “no filter” for channel query parameters. User group names are sourced from the group-ratio map and are not restricted from using either value, so searching users for one of those groups now returns the unfiltered user list instead of matching the exact group token. Keep the channel sentinel handling separate from the user-group predicate.
query = ApplyGroupContainsFilter(query, group)
service/group_multi_test.go:91
- This test changes the process-wide AutoGroups setting but always restores it to
[], so it can erase a pre-existing configuration and leak state into later tests when the suite is run with non-empty defaults. Capture the original JSON before updating it and restore that exact value in cleanup, as the neighboring auto-group tests do.
require.NoError(t, setting.UpdateAutoGroupsByJsonString(`["vip","group1"]`))
t.Cleanup(func() {
require.NoError(t, setting.UpdateAutoGroupsByJsonString("[]"))
})
- Files reviewed: 28/28 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // 如果分组不在UserUsableGroups中,补充该分组本身 | ||
| if _, ok := groupsCopy[group]; !ok { | ||
| groupsCopy[group] = "用户分组" | ||
| } | ||
| } | ||
| return groupsCopy |
There was a problem hiding this comment.
Fixed in d52111b: GetUserUsableGroups now runs in two passes — special rules are applied per group in list order first, then all owned groups are re-added afterwards, so an owned group can never be removed by another member's -: rule (matching the single-group semantics). A test assertion covers the -:group0 case.
| // 多分组用户按主分组(第一个分组)计费 | ||
| group = common.PrimaryGroup(user.Group) |
There was a problem hiding this comment.
Fixed in d52111b: RecalculateTaskQuotaByTokens now loads the user's full group list via GetUserById and passes it as the first argument to GetGroupGroupRatio, while task.Group stays as the using group. If the user lookup fails it falls back to the previous exact-match behavior.
| group := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) | ||
| if group == "" { | ||
| group = common.GetContextKeyString(c, constant.ContextKeyUserGroup) | ||
| group = common.PrimaryGroup(common.GetContextKeyString(c, constant.ContextKeyUserGroup)) |
There was a problem hiding this comment.
Fixed in d52111b: the ModelRequestRateLimit lookup now applies the same resolution — token group first, then common.PrimaryGroup(ContextKeyUserGroup) — so a multi-group user matches the configured per-group rate limit instead of silently falling back to global limits.
| <MultiSelect | ||
| options={groupOptions} | ||
| selected={selectedGroups} | ||
| onChange={(values) => | ||
| field.onChange(values.join(',')) | ||
| } | ||
| placeholder={t('Select groups')} | ||
| /> |
There was a problem hiding this comment.
Fixed in d52111b: MultiSelect now accepts and forwards aria-describedby / aria-invalid to the underlying ComboboxChipsInput, so the FormControl-injected ids reach the real input element for screen readers.
| export function parseGroupList(value?: string | null): string[] { | ||
| if (!value) { | ||
| return [] | ||
| } | ||
| return value | ||
| .split(',') | ||
| .map((group) => group.trim()) | ||
| .filter((group) => group.length > 0) |
There was a problem hiding this comment.
Added web/src/lib/group-list.test.ts in d52111b covering whitespace trimming, empty-entry handling, primary-order (first entry is the primary group), and no-substring-splitting.
- GetUserUsableGroups 改为两段式:先按列表顺序应用特殊可用规则,最后统一补回所属分组,保证所属分组不可被其他分组的 '-:' 规则移除(与单分组语义一致),并补充测试断言该不变量 - 任务结算 RecalculateTaskQuotaByTokens 将用户完整分组列表作为用户-分组专属倍率的首参,task.Group 仅作为使用分组,避免多分组用户丢失专属倍率;用户查询失败时回退旧的精确匹配行为 - ModelRequestRateLimit 限流查找同样取主分组,避免逗号列表无法命中分组限流配置 - MultiSelect 转发 aria-describedby/aria-invalid 到内部输入框,FormControl 的无障碍接线可达读屏 - 新增 parseGroupList 前端单测(空白/空项/顺序/子串)
Important
Note
本 PR 代码由 AI(ZCode agent)在本人指导下生成/协助完成,描述已人工整理。
📝 变更描述 / Description
用户分组不再唯一:
users.group存储逗号分隔的分组列表(如group0,group1),第一个为主分组,存储与解析风格与渠道多分组保持一致。common.SplitGroupList / PrimaryGroup / NormalizeGroupList作为统一的逗号分组列表解析入口;GetTopupGroupRatio同样按列表顺序取第一个命中的配置GetGroupGroupRatio对多分组用户按列表顺序取第一个命中的用户-分组专属倍率,无命中回退全局分组倍率(relay 计费、任务计费、定价接口共用该语义)GetUserUsableGroups对每个所属分组合并特殊可用规则(按列表顺序应用,后分组可移除先分组新增的分组)并保证所属分组本身可选;令牌分组与 Playground 分组的权限校验随之基于该并集relayInfo.TokenGroup及任务计费、限流日志等单值消费点均取主分组,保证渠道精确匹配不会收到逗号列表ApplyGroupContainsFilter,group1不会误命中group10);编辑用户时规范化分组列表(去空格/去重/去空项,空回落default)并校验长度(单组 ≤64,总长 ≤1024)users.group与user_subscriptions.prev_user_group加宽至varchar(1024)(AutoMigrate 在 MySQL/PG 一次性加宽,SQLite 类型亲和天然兼容;订阅升级/到期仍是"整体替换为套餐分组"语义,升级前将原列表快照进PrevUserGroup)🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
本地验证(全部通过):
go build ./...、go vet(涉及包)go test ./...全量通过,新增多分组合约测试:common/group_test.go、service/group_multi_test.go(可用分组并集/特殊规则顺序/倍率首个命中/Auto 分组)、model/user_group_test.go(SearchUsers 包含匹配、EditWithTx 规范化)tsgo类型检查、oxlint(无新增告警)、oxfmt 格式检查、生产构建、vitest 208 个测试全部通过