diff --git a/README.md b/README.md index 0d1fe23ae..f72f7cfc1 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,18 @@ Model authenticity: no content intervention or secondary filtering — experienc + +claudeapi +Thanks to Claude API for sponsoring this project! Claude API is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the exclusive link and contact customer support to receive free trial credits; invoicing and team onboarding are also supported. + + + + +code0 +Thanks to code0.ai for sponsoring this project! code0.ai is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the exclusive link and contact customer support to receive free trial credits and experience a more efficient AI coding workflow. + + + ## Overview diff --git a/README_CN.md b/README_CN.md index b1139dd12..bc33ad82a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -172,6 +172,18 @@ + +claudeapi +感谢 Claude API 对本项目的赞助! Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过[专属链接]注册后联系客服,可领取免费测试额度,并支持开票和团队对接。 + + + + +code0 +感谢 code0.ai 对本项目的赞助! code0.ai 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过[专属链接]注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。 + + + ## 项目概述 diff --git a/README_JA.md b/README_JA.md index ba556139e..90541cadf 100644 --- a/README_JA.md +++ b/README_JA.md @@ -167,6 +167,18 @@ + +claudeapi +Claude API のご支援に感謝します!Claude API は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。 + + + + +code0 +code0.ai のご支援に感謝します!code0.ai は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。 + + + ## 概要 diff --git a/assets/partners/logos/claudeapi.jpg b/assets/partners/logos/claudeapi.jpg new file mode 100644 index 000000000..026c0f039 Binary files /dev/null and b/assets/partners/logos/claudeapi.jpg differ diff --git a/assets/partners/logos/code0.jpg b/assets/partners/logos/code0.jpg new file mode 100644 index 000000000..b185afacb Binary files /dev/null and b/assets/partners/logos/code0.jpg differ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0214e6937..b0b5ddaf6 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.156 +0.1.157 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index bb4205231..3a173cbab 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -173,11 +173,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { leaderLockCache := repository.NewLeaderLockCache(redisClient) dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) + adminGroupRepository := repository.NewAdminGroupRepository(client, db) adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) - adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache) + adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) + adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) diff --git a/backend/ent/group.go b/backend/ent/group.go index 088da069d..e6ffdec45 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -43,6 +43,8 @@ type Group struct { IsExclusive bool `json:"is_exclusive,omitempty"` // Status holds the value of the "status" field. Status string `json:"status,omitempty"` + // 内部幂等恢复标识,不对 API 暴露 + DuplicateOperationID *string `json:"duplicate_operation_id,omitempty"` // Platform holds the value of the "platform" field. Platform string `json:"platform,omitempty"` // SubscriptionType holds the value of the "subscription_type" field. @@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) - case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: + case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel: values[i] = new(sql.NullString) case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -328,6 +330,13 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Status = value.String } + case group.FieldDuplicateOperationID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field duplicate_operation_id", values[i]) + } else if value.Valid { + _m.DuplicateOperationID = new(string) + *_m.DuplicateOperationID = value.String + } case group.FieldPlatform: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field platform", values[i]) @@ -680,6 +689,11 @@ func (_m *Group) String() string { builder.WriteString("status=") builder.WriteString(_m.Status) builder.WriteString(", ") + if v := _m.DuplicateOperationID; v != nil { + builder.WriteString("duplicate_operation_id=") + builder.WriteString(*v) + } + builder.WriteString(", ") builder.WriteString("platform=") builder.WriteString(_m.Platform) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 61d7a21d6..efe2eaa06 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -40,6 +40,8 @@ const ( FieldIsExclusive = "is_exclusive" // FieldStatus holds the string denoting the status field in the database. FieldStatus = "status" + // FieldDuplicateOperationID holds the string denoting the duplicate_operation_id field in the database. + FieldDuplicateOperationID = "duplicate_operation_id" // FieldPlatform holds the string denoting the platform field in the database. FieldPlatform = "platform" // FieldSubscriptionType holds the string denoting the subscription_type field in the database. @@ -199,6 +201,7 @@ var Columns = []string{ FieldPeakRateMultiplier, FieldIsExclusive, FieldStatus, + FieldDuplicateOperationID, FieldPlatform, FieldSubscriptionType, FieldDailyLimitUsd, @@ -292,6 +295,8 @@ var ( DefaultStatus string // StatusValidator is a validator for the "status" field. It is called by the builders before save. StatusValidator func(string) error + // DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. + DuplicateOperationIDValidator func(string) error // DefaultPlatform holds the default value on creation for the "platform" field. DefaultPlatform string // PlatformValidator is a validator for the "platform" field. It is called by the builders before save. @@ -414,6 +419,11 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldStatus, opts...).ToFunc() } +// ByDuplicateOperationID orders the results by the duplicate_operation_id field. +func ByDuplicateOperationID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDuplicateOperationID, opts...).ToFunc() +} + // ByPlatform orders the results by the platform field. func ByPlatform(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldPlatform, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index b9a52a2eb..441326dba 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -115,6 +115,11 @@ func Status(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldStatus, v)) } +// DuplicateOperationID applies equality check predicate on the "duplicate_operation_id" field. It's identical to DuplicateOperationIDEQ. +func DuplicateOperationID(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v)) +} + // Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ. func Platform(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldPlatform, v)) @@ -840,6 +845,81 @@ func StatusContainsFold(v string) predicate.Group { return predicate.Group(sql.FieldContainsFold(FieldStatus, v)) } +// DuplicateOperationIDEQ applies the EQ predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDEQ(v string) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDNEQ applies the NEQ predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNEQ(v string) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDIn applies the In predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldIn(FieldDuplicateOperationID, vs...)) +} + +// DuplicateOperationIDNotIn applies the NotIn predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNotIn(vs ...string) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldDuplicateOperationID, vs...)) +} + +// DuplicateOperationIDGT applies the GT predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDGT(v string) predicate.Group { + return predicate.Group(sql.FieldGT(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDGTE applies the GTE predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDGTE(v string) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDLT applies the LT predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDLT(v string) predicate.Group { + return predicate.Group(sql.FieldLT(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDLTE applies the LTE predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDLTE(v string) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDContains applies the Contains predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDContains(v string) predicate.Group { + return predicate.Group(sql.FieldContains(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDHasPrefix applies the HasPrefix predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDHasPrefix(v string) predicate.Group { + return predicate.Group(sql.FieldHasPrefix(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDHasSuffix applies the HasSuffix predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDHasSuffix(v string) predicate.Group { + return predicate.Group(sql.FieldHasSuffix(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDIsNil applies the IsNil predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDIsNil() predicate.Group { + return predicate.Group(sql.FieldIsNull(FieldDuplicateOperationID)) +} + +// DuplicateOperationIDNotNil applies the NotNil predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDNotNil() predicate.Group { + return predicate.Group(sql.FieldNotNull(FieldDuplicateOperationID)) +} + +// DuplicateOperationIDEqualFold applies the EqualFold predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDEqualFold(v string) predicate.Group { + return predicate.Group(sql.FieldEqualFold(FieldDuplicateOperationID, v)) +} + +// DuplicateOperationIDContainsFold applies the ContainsFold predicate on the "duplicate_operation_id" field. +func DuplicateOperationIDContainsFold(v string) predicate.Group { + return predicate.Group(sql.FieldContainsFold(FieldDuplicateOperationID, v)) +} + // PlatformEQ applies the EQ predicate on the "platform" field. func PlatformEQ(v string) predicate.Group { return predicate.Group(sql.FieldEQ(FieldPlatform, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 53fd733c1..207b54fee 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -189,6 +189,20 @@ func (_c *GroupCreate) SetNillableStatus(v *string) *GroupCreate { return _c } +// SetDuplicateOperationID sets the "duplicate_operation_id" field. +func (_c *GroupCreate) SetDuplicateOperationID(v string) *GroupCreate { + _c.mutation.SetDuplicateOperationID(v) + return _c +} + +// SetNillableDuplicateOperationID sets the "duplicate_operation_id" field if the given value is not nil. +func (_c *GroupCreate) SetNillableDuplicateOperationID(v *string) *GroupCreate { + if v != nil { + _c.SetDuplicateOperationID(*v) + } + return _c +} + // SetPlatform sets the "platform" field. func (_c *GroupCreate) SetPlatform(v string) *GroupCreate { _c.mutation.SetPlatform(v) @@ -993,6 +1007,11 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Group.status": %w`, err)} } } + if v, ok := _c.mutation.DuplicateOperationID(); ok { + if err := group.DuplicateOperationIDValidator(v); err != nil { + return &ValidationError{Name: "duplicate_operation_id", err: fmt.Errorf(`ent: validator failed for field "Group.duplicate_operation_id": %w`, err)} + } + } if _, ok := _c.mutation.Platform(); !ok { return &ValidationError{Name: "platform", err: errors.New(`ent: missing required field "Group.platform"`)} } @@ -1152,6 +1171,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldStatus, field.TypeString, value) _node.Status = value } + if value, ok := _c.mutation.DuplicateOperationID(); ok { + _spec.SetField(group.FieldDuplicateOperationID, field.TypeString, value) + _node.DuplicateOperationID = &value + } if value, ok := _c.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) _node.Platform = value @@ -2246,6 +2269,9 @@ func (u *GroupUpsertOne) UpdateNewValues() *GroupUpsertOne { if _, exists := u.create.mutation.CreatedAt(); exists { s.SetIgnore(group.FieldCreatedAt) } + if _, exists := u.create.mutation.DuplicateOperationID(); exists { + s.SetIgnore(group.FieldDuplicateOperationID) + } })) return u } @@ -3366,6 +3392,9 @@ func (u *GroupUpsertBulk) UpdateNewValues() *GroupUpsertBulk { if _, exists := b.mutation.CreatedAt(); exists { s.SetIgnore(group.FieldCreatedAt) } + if _, exists := b.mutation.DuplicateOperationID(); exists { + s.SetIgnore(group.FieldDuplicateOperationID) + } } })) return u diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 1e767a139..234e4fe96 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1273,6 +1273,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Status(); ok { _spec.SetField(group.FieldStatus, field.TypeString, value) } + if _u.mutation.DuplicateOperationIDCleared() { + _spec.ClearField(group.FieldDuplicateOperationID, field.TypeString) + } if value, ok := _u.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) } @@ -3063,6 +3066,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.Status(); ok { _spec.SetField(group.FieldStatus, field.TypeString, value) } + if _u.mutation.DuplicateOperationIDCleared() { + _spec.ClearField(group.FieldDuplicateOperationID, field.TypeString) + } if value, ok := _u.mutation.Platform(); ok { _spec.SetField(group.FieldPlatform, field.TypeString, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 994780cd4..c09f1f26a 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -845,6 +845,7 @@ var ( {Name: "peak_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "is_exclusive", Type: field.TypeBool, Default: false}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "duplicate_operation_id", Type: field.TypeString, Nullable: true, Size: 64}, {Name: "platform", Type: field.TypeString, Size: 50, Default: "anthropic"}, {Name: "subscription_type", Type: field.TypeString, Size: 20, Default: "standard"}, {Name: "daily_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, @@ -896,12 +897,12 @@ var ( { Name: "group_platform", Unique: false, - Columns: []*schema.Column{GroupsColumns[13]}, + Columns: []*schema.Column{GroupsColumns[14]}, }, { Name: "group_subscription_type", Unique: false, - Columns: []*schema.Column{GroupsColumns[14]}, + Columns: []*schema.Column{GroupsColumns[15]}, }, { Name: "group_is_exclusive", @@ -916,7 +917,15 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[41]}, + Columns: []*schema.Column{GroupsColumns[42]}, + }, + { + Name: "idx_groups_duplicate_operation_id_active", + Unique: true, + Columns: []*schema.Column{GroupsColumns[13]}, + Annotation: &entsql.IndexAnnotation{ + Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL", + }, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 46b97462e..9e746e19d 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -20808,6 +20808,7 @@ type GroupMutation struct { addpeak_rate_multiplier *float64 is_exclusive *bool status *string + duplicate_operation_id *string platform *string subscription_type *string daily_limit_usd *float64 @@ -21484,6 +21485,55 @@ func (m *GroupMutation) ResetStatus() { m.status = nil } +// SetDuplicateOperationID sets the "duplicate_operation_id" field. +func (m *GroupMutation) SetDuplicateOperationID(s string) { + m.duplicate_operation_id = &s +} + +// DuplicateOperationID returns the value of the "duplicate_operation_id" field in the mutation. +func (m *GroupMutation) DuplicateOperationID() (r string, exists bool) { + v := m.duplicate_operation_id + if v == nil { + return + } + return *v, true +} + +// OldDuplicateOperationID returns the old "duplicate_operation_id" 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) OldDuplicateOperationID(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDuplicateOperationID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDuplicateOperationID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDuplicateOperationID: %w", err) + } + return oldValue.DuplicateOperationID, nil +} + +// ClearDuplicateOperationID clears the value of the "duplicate_operation_id" field. +func (m *GroupMutation) ClearDuplicateOperationID() { + m.duplicate_operation_id = nil + m.clearedFields[group.FieldDuplicateOperationID] = struct{}{} +} + +// DuplicateOperationIDCleared returns if the "duplicate_operation_id" field was cleared in this mutation. +func (m *GroupMutation) DuplicateOperationIDCleared() bool { + _, ok := m.clearedFields[group.FieldDuplicateOperationID] + return ok +} + +// ResetDuplicateOperationID resets all changes to the "duplicate_operation_id" field. +func (m *GroupMutation) ResetDuplicateOperationID() { + m.duplicate_operation_id = nil + delete(m.clearedFields, group.FieldDuplicateOperationID) +} + // SetPlatform sets the "platform" field. func (m *GroupMutation) SetPlatform(s string) { m.platform = &s @@ -23714,7 +23764,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, 48) + fields := make([]string, 0, 49) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -23751,6 +23801,9 @@ func (m *GroupMutation) Fields() []string { if m.status != nil { fields = append(fields, group.FieldStatus) } + if m.duplicate_operation_id != nil { + fields = append(fields, group.FieldDuplicateOperationID) + } if m.platform != nil { fields = append(fields, group.FieldPlatform) } @@ -23891,6 +23944,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.IsExclusive() case group.FieldStatus: return m.Status() + case group.FieldDuplicateOperationID: + return m.DuplicateOperationID() case group.FieldPlatform: return m.Platform() case group.FieldSubscriptionType: @@ -23996,6 +24051,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldIsExclusive(ctx) case group.FieldStatus: return m.OldStatus(ctx) + case group.FieldDuplicateOperationID: + return m.OldDuplicateOperationID(ctx) case group.FieldPlatform: return m.OldPlatform(ctx) case group.FieldSubscriptionType: @@ -24161,6 +24218,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetStatus(v) return nil + case group.FieldDuplicateOperationID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDuplicateOperationID(v) + return nil case group.FieldPlatform: v, ok := value.(string) if !ok { @@ -24704,6 +24768,9 @@ func (m *GroupMutation) ClearedFields() []string { if m.FieldCleared(group.FieldDescription) { fields = append(fields, group.FieldDescription) } + if m.FieldCleared(group.FieldDuplicateOperationID) { + fields = append(fields, group.FieldDuplicateOperationID) + } if m.FieldCleared(group.FieldDailyLimitUsd) { fields = append(fields, group.FieldDailyLimitUsd) } @@ -24763,6 +24830,9 @@ func (m *GroupMutation) ClearField(name string) error { case group.FieldDescription: m.ClearDescription() return nil + case group.FieldDuplicateOperationID: + m.ClearDuplicateOperationID() + return nil case group.FieldDailyLimitUsd: m.ClearDailyLimitUsd() return nil @@ -24846,6 +24916,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldStatus: m.ResetStatus() return nil + case group.FieldDuplicateOperationID: + m.ResetDuplicateOperationID() + return nil case group.FieldPlatform: m.ResetPlatform() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index f13e14c51..42dbff5da 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -995,102 +995,106 @@ func init() { group.DefaultStatus = groupDescStatus.Default.(string) // group.StatusValidator is a validator for the "status" field. It is called by the builders before save. group.StatusValidator = groupDescStatus.Validators[0].(func(string) error) + // groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field. + groupDescDuplicateOperationID := groupFields[9].Descriptor() + // group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. + group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error) // groupDescPlatform is the schema descriptor for platform field. - groupDescPlatform := groupFields[9].Descriptor() + groupDescPlatform := groupFields[10].Descriptor() // group.DefaultPlatform holds the default value on creation for the platform field. group.DefaultPlatform = groupDescPlatform.Default.(string) // group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save. group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error) // groupDescSubscriptionType is the schema descriptor for subscription_type field. - groupDescSubscriptionType := groupFields[10].Descriptor() + groupDescSubscriptionType := groupFields[11].Descriptor() // group.DefaultSubscriptionType holds the default value on creation for the subscription_type field. group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string) // group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save. group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error) // groupDescDefaultValidityDays is the schema descriptor for default_validity_days field. - groupDescDefaultValidityDays := groupFields[14].Descriptor() + groupDescDefaultValidityDays := groupFields[15].Descriptor() // group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field. group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int) // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field. - groupDescAllowImageGeneration := groupFields[15].Descriptor() + groupDescAllowImageGeneration := groupFields[16].Descriptor() // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) // groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field. - groupDescAllowBatchImageGeneration := groupFields[16].Descriptor() + groupDescAllowBatchImageGeneration := groupFields[17].Descriptor() // group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field. group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool) // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. - groupDescImageRateIndependent := groupFields[17].Descriptor() + groupDescImageRateIndependent := groupFields[18].Descriptor() // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field. group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool) // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field. - groupDescImageRateMultiplier := groupFields[18].Descriptor() + groupDescImageRateMultiplier := groupFields[19].Descriptor() // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field. - groupDescBatchImageDiscountMultiplier := groupFields[22].Descriptor() + groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor() // group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field. group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64) // groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field. - groupDescBatchImageHoldMultiplier := groupFields[23].Descriptor() + groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor() // group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field. group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64) // groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field. - groupDescVideoRateIndependent := groupFields[24].Descriptor() + groupDescVideoRateIndependent := groupFields[25].Descriptor() // group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field. group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool) // groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field. - groupDescVideoRateMultiplier := groupFields[25].Descriptor() + groupDescVideoRateMultiplier := groupFields[26].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[30].Descriptor() + groupDescClaudeCodeOnly := groupFields[31].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[34].Descriptor() + groupDescModelRoutingEnabled := groupFields[35].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[35].Descriptor() + groupDescMcpXMLInject := groupFields[36].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[36].Descriptor() + groupDescSupportedModelScopes := groupFields[37].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[37].Descriptor() + groupDescSortOrder := groupFields[38].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[38].Descriptor() + groupDescAllowMessagesDispatch := groupFields[39].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[39].Descriptor() + groupDescRequireOauthOnly := groupFields[40].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[40].Descriptor() + groupDescRequirePrivacySet := groupFields[41].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[41].Descriptor() + groupDescDefaultMappedModel := groupFields[42].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[43].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[43].Descriptor() + groupDescModelsListConfig := groupFields[44].Descriptor() // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[44].Descriptor() + groupDescRpmLimit := groupFields[45].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 70093a3cf..325211252 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -66,6 +66,12 @@ func (Group) Fields() []ent.Field { field.String("status"). MaxLen(20). Default(domain.StatusActive), + field.String("duplicate_operation_id"). + MaxLen(64). + Optional(). + Nillable(). + Immutable(). + Comment("内部幂等恢复标识,不对 API 暴露"), // Subscription-related fields (added by migration 003) field.String("platform"). @@ -244,5 +250,9 @@ func (Group) Indexes() []ent.Index { index.Fields("is_exclusive"), index.Fields("deleted_at"), index.Fields("sort_order"), + index.Fields("duplicate_operation_id"). + Unique(). + StorageKey("idx_groups_duplicate_operation_id_active"). + Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")), } } diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index bffddc8a7..23b20f4e9 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { router := gin.New() adminSvc := newStubAdminService() - userHandler := NewUserHandler(adminSvc, nil, nil, nil) + userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) groupHandler := NewGroupHandler(adminSvc, nil, nil) proxyHandler := NewProxyHandler(adminSvc) redeemHandler := NewRedeemHandler(adminSvc, nil) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index d93ee7111..f5c238a61 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -195,6 +195,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [ return len(userIDs), nil } +func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + return len(userIDs), nil +} + func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } @@ -293,6 +297,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat return &group, nil } +func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"} + return &group, nil +} + +func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + return nil, nil +} + func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) { group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive} return &group, nil diff --git a/backend/internal/handler/admin/channel_monitor_duplicate_test.go b/backend/internal/handler/admin/channel_monitor_duplicate_test.go new file mode 100644 index 000000000..85670ca7b --- /dev/null +++ b/backend/internal/handler/admin/channel_monitor_duplicate_test.go @@ -0,0 +1,170 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type duplicateChannelMonitorHandlerRepoStub struct { + service.ChannelMonitorRepository + source *service.ChannelMonitor + byOperation map[string]*service.ChannelMonitor + createCalls int +} + +func (r *duplicateChannelMonitorHandlerRepoStub) GetByID(_ context.Context, id int64) (*service.ChannelMonitor, error) { + if r.source == nil || r.source.ID != id { + return nil, service.ErrChannelMonitorNotFound + } + return r.source, nil +} + +func (r *duplicateChannelMonitorHandlerRepoStub) Create(_ context.Context, monitor *service.ChannelMonitor) error { + r.createCalls++ + monitor.ID = int64(100 + r.createCalls) + stored := *monitor + if stored.DuplicateOperationID != "" { + if r.byOperation == nil { + r.byOperation = make(map[string]*service.ChannelMonitor) + } + r.byOperation[stored.DuplicateOperationID] = &stored + } + return nil +} + +func (r *duplicateChannelMonitorHandlerRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*service.ChannelMonitor, error) { + monitor := r.byOperation[operationID] + if monitor == nil { + return nil, nil + } + cloned := *monitor + return &cloned, nil +} + +type duplicateChannelMonitorHandlerEncryptor struct{} + +func (duplicateChannelMonitorHandlerEncryptor) Encrypt(plaintext string) (string, error) { + return "ENC:" + plaintext, nil +} + +func (duplicateChannelMonitorHandlerEncryptor) Decrypt(ciphertext string) (string, error) { + return strings.TrimPrefix(ciphertext, "ENC:"), nil +} + +func setupDuplicateChannelMonitorRouter(t *testing.T) (*gin.Engine, *duplicateChannelMonitorHandlerRepoStub) { + t.Helper() + previousCoordinator := service.DefaultIdempotencyCoordinator() + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator( + newMemoryIdempotencyRepoStub(), + service.DefaultIdempotencyConfig(), + )) + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) }) + + repo := &duplicateChannelMonitorHandlerRepoStub{ + source: &service.ChannelMonitor{ + ID: 42, + Name: "primary", + Provider: service.MonitorProviderOpenAI, + APIMode: service.MonitorAPIModeResponses, + Endpoint: "https://api.example.com", + APIKey: "ENC:top-secret", + PrimaryModel: "gpt-5.4-mini", + ExtraModels: []string{"gpt-5.4"}, + Enabled: true, + IntervalSeconds: 60, + BodyOverrideMode: service.MonitorBodyOverrideModeOff, + }, + } + monitorService := service.NewChannelMonitorService(repo, duplicateChannelMonitorHandlerEncryptor{}) + handler := NewChannelMonitorHandler(monitorService) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77}) + c.Next() + }) + router.POST("/api/v1/admin/channel-monitors/:id/duplicate", handler.Duplicate) + return router, repo +} + +func TestDuplicateChannelMonitorHandlerRedactsKeyAndReplaysRetry(t *testing.T) { + router, repo := setupDuplicateChannelMonitorRouter(t) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42") + first := httptest.NewRecorder() + router.ServeHTTP(first, request) + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, 1, repo.createCalls) + require.Contains(t, first.Body.String(), `"name":"primary (Copy)"`) + require.Contains(t, first.Body.String(), `"api_key_masked":"top-***"`) + require.Contains(t, first.Body.String(), `"created_by":77`) + require.Contains(t, first.Body.String(), `"enabled":false`) + require.NotContains(t, first.Body.String(), "top-secret") + + retryRequest := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil) + retryRequest.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42") + retry := httptest.NewRecorder() + router.ServeHTTP(retry, retryRequest) + + require.Equal(t, http.StatusOK, retry.Code) + require.Equal(t, "true", retry.Header().Get("X-Idempotency-Replayed")) + require.Equal(t, 1, repo.createCalls) + require.JSONEq(t, first.Body.String(), retry.Body.String()) +} + +func TestDuplicateChannelMonitorHandlerRejectsInvalidID(t *testing.T) { + router, repo := setupDuplicateChannelMonitorRouter(t) + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/not-a-number/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-invalid") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, repo.createCalls) +} + +func TestDuplicateChannelMonitorHandlerRecoversAfterMarkSucceededFailure(t *testing.T) { + router, repo := setupDuplicateChannelMonitorRouter(t) + idempotencyRepo := &failOnceMarkSucceededRepo{ + memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), + failNext: true, + } + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator( + idempotencyRepo, + service.DefaultIdempotencyConfig(), + )) + + call := func() *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-recovery") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, 1, repo.createCalls, "ambiguous retries must not repeat the create side effect") + require.Contains(t, second.Body.String(), `"id":101`) + require.Contains(t, second.Body.String(), `"api_key_masked":"top-***"`) + require.NotContains(t, second.Body.String(), "top-secret") +} diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index a69b83584..ec5735f0c 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -1,6 +1,8 @@ package admin import ( + "context" + "log/slog" "strconv" "strings" "time" @@ -333,6 +335,63 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) { response.Created(c, channelMonitorToResponse(m)) } +// Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate +func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) { + id, ok := ParseChannelMonitorID(c) + if !ok { + return + } + subject, _ := middleware2.GetAuthSubjectFromContext(c) + actorScope := adminActorScope(c) + + result, err := executeAdminIdempotent( + c, + "admin.channel_monitors.duplicate", + struct { + MonitorID int64 `json:"monitor_id"` + }{MonitorID: id}, + service.DefaultWriteIdempotencyTTL(), + func(ctx context.Context) (any, error) { + monitor, err := h.monitorService.Duplicate( + ctx, + id, + subject.UserID, + actorScope, + c.GetHeader("Idempotency-Key"), + ) + if err != nil { + return nil, err + } + return channelMonitorToResponse(monitor), nil + }, + ) + if err != nil { + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) { + recovered, recoverErr := h.monitorService.RecoverDuplicate( + c.Request.Context(), + id, + actorScope, + c.GetHeader("Idempotency-Key"), + ) + if recoverErr != nil { + slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr) + } else if recovered != nil { + c.Header("X-Idempotency-Recovered", "true") + response.Success(c, channelMonitorToResponse(recovered)) + return + } + } + response.ErrorFrom(c, err) + return + } + + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } + response.Success(c, result.Data) +} + // Update PUT /api/v1/admin/channel-monitors/:id func (h *ChannelMonitorHandler) Update(c *gin.Context) { id, ok := ParseChannelMonitorID(c) diff --git a/backend/internal/handler/admin/grok_oauth_handler.go b/backend/internal/handler/admin/grok_oauth_handler.go index 258c68853..ac05fd8a6 100644 --- a/backend/internal/handler/admin/grok_oauth_handler.go +++ b/backend/internal/handler/admin/grok_oauth_handler.go @@ -373,8 +373,7 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}} } - credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo) - credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials) + credentials := grokSSOImportCredentials(h.grokOAuthService.BuildAccountCredentials(tokenInfo), req.Credentials) name := grokSSOImportAccountName(req.Name, tokenInfo, index, total) expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo) account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ @@ -408,6 +407,18 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr } } +// grokSSOImportCredentials 合并 SSO 兑换出的凭据与导入请求携带的运营侧配置。 +// token 字段以 BuildAccountCredentials 为准(请求不可覆盖);但 base_url 是运营侧 +// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与 +// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。 +func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any { + credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built) + if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" { + credentials["base_url"] = strings.TrimSpace(reqBaseURL) + } + return credentials +} + func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) { if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 { return requestExpiresAt, requestAutoPause diff --git a/backend/internal/handler/admin/grok_oauth_handler_test.go b/backend/internal/handler/admin/grok_oauth_handler_test.go index a87afa8c1..9d87fc0a9 100644 --- a/backend/internal/handler/admin/grok_oauth_handler_test.go +++ b/backend/internal/handler/admin/grok_oauth_handler_test.go @@ -223,6 +223,45 @@ func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing. require.Same(t, &requestedAutoPause, autoPause) } +func TestGrokSSOImportCredentialsPreservesRequestedBaseURL(t *testing.T) { + built := map[string]any{ + "access_token": "at-1", + "base_url": xai.DefaultCLIBaseURL, + } + reqCredentials := map[string]any{ + "base_url": "https://relay.example.com/v1", + "header_override_enabled": true, + "header_overrides": map[string]any{"x-relay-key": "k"}, + } + + credentials := grokSSOImportCredentials(built, reqCredentials) + + // token 字段以兑换结果为准;base_url 是运营侧配置,必须保留请求里的自定义地址 + require.Equal(t, "at-1", credentials["access_token"]) + require.Equal(t, "https://relay.example.com/v1", credentials["base_url"]) + require.Equal(t, true, credentials["header_override_enabled"]) + require.Equal(t, map[string]any{"x-relay-key": "k"}, credentials["header_overrides"]) + // 入参不被污染(req.Credentials 会被多个 worker 并发读取) + require.Equal(t, "https://relay.example.com/v1", reqCredentials["base_url"]) +} + +func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) { + built := map[string]any{ + "access_token": "at-1", + "base_url": xai.DefaultCLIBaseURL, + } + + credentials := grokSSOImportCredentials(built, nil) + require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"]) + + credentials = grokSSOImportCredentials(map[string]any{ + "access_token": "at-2", + "base_url": xai.DefaultCLIBaseURL, + }, map[string]any{"base_url": " "}) + require.Equal(t, xai.DefaultCLIBaseURL, credentials["base_url"]) + require.Equal(t, "at-2", credentials["access_token"]) +} + func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) { h := &GrokOAuthHandler{} result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 02a444684..0f44d5531 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -2,12 +2,15 @@ package admin import ( "bytes" + "context" "encoding/json" "fmt" + "log/slog" "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" @@ -361,6 +364,53 @@ func (h *GroupHandler) Create(c *gin.Context) { response.Success(c, dto.GroupFromServiceAdmin(group)) } +// Duplicate handles creating an inactive group copy with the source account bindings. +// POST /api/v1/admin/groups/:id/duplicate +func (h *GroupHandler) Duplicate(c *gin.Context) { + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || groupID <= 0 { + response.BadRequest(c, "Invalid group ID") + return + } + actorScope := adminActorScope(c) + + result, err := executeAdminIdempotent( + c, + "admin.groups.duplicate", + struct { + GroupID int64 `json:"group_id"` + }{GroupID: groupID}, + service.DefaultWriteIdempotencyTTL(), + func(ctx context.Context) (any, error) { + group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key")) + if execErr != nil { + return nil, execErr + } + return dto.GroupFromServiceAdmin(group), nil + }, + ) + if err != nil { + reason := infraerrors.Reason(err) + if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) { + recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key")) + if recoverErr != nil { + slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr) + } else if recovered != nil { + c.Header("X-Idempotency-Recovered", "true") + response.Success(c, dto.GroupFromServiceAdmin(recovered)) + return + } + } + response.ErrorFrom(c, err) + return + } + + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } + response.Success(c, result.Data) +} + // Update handles updating a group // PUT /api/v1/admin/groups/:id func (h *GroupHandler) Update(c *gin.Context) { diff --git a/backend/internal/handler/admin/group_handler_duplicate_test.go b/backend/internal/handler/admin/group_handler_duplicate_test.go new file mode 100644 index 000000000..a77b93582 --- /dev/null +++ b/backend/internal/handler/admin/group_handler_duplicate_test.go @@ -0,0 +1,163 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type duplicateGroupAdminServiceStub struct { + service.AdminService + group *service.Group + calls int + recoverCalls int + groupID int64 + actorScope string + operationKey string + recoverScope string + recoverKey string + created bool +} + +func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) { + s.calls++ + s.groupID = groupID + s.actorScope = actorScope + s.operationKey = operationKey + s.created = true + return s.group, nil +} + +func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) { + s.recoverCalls++ + s.recoverScope = actorScope + s.recoverKey = operationKey + if !s.created { + return nil, nil + } + return s.group, nil +} + +func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine { + t.Helper() + previousCoordinator := service.DefaultIdempotencyCoordinator() + service.SetDefaultIdempotencyCoordinator(nil) + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) }) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77}) + c.Next() + }) + handler := NewGroupHandler(svc, nil, nil) + router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate) + return router +} + +func duplicateGroupHandlerFixture() *service.Group { + return &service.Group{ + ID: 43, + Name: "primary (Copy)", + Platform: service.PlatformAnthropic, + Status: "inactive", + RateMultiplier: 1, + AccountCount: 3, + ActiveAccountCount: 2, + DuplicateOperationID: "internal-operation-must-not-leak", + ModelRouting: map[string][]int64{"claude-*": {7}}, + } +} + +func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, svc.calls) + require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`) + require.Contains(t, recorder.Body.String(), `"status":"inactive"`) + require.Contains(t, recorder.Body.String(), `"account_count":3`) + require.NotContains(t, recorder.Body.String(), "duplicate_operation_id") + require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak") +} + +func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) { + for _, id := range []string{"not-a-number", "0", "-1"} { + t.Run(id, func(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{} + router := setupDuplicateGroupRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, svc.calls) + }) + } +} + +func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig())) + + call := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-group-42") + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, 1, svc.calls) + require.Equal(t, int64(42), svc.groupID) + require.Equal(t, "admin:77", svc.actorScope) + require.Equal(t, "duplicate-group-42", svc.operationKey) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed")) +} + +func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) { + svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()} + router := setupDuplicateGroupRouter(t, svc) + repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true} + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())) + + call := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery") + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered")) + require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect") + require.Equal(t, 2, svc.recoverCalls) + require.Equal(t, "admin:77", svc.recoverScope) + require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey) +} diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 534f774e8..cdcc762a8 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/quotaview" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -30,6 +31,8 @@ type UserHandler struct { concurrencyService *service.ConcurrencyService userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径) + totpService *service.TotpService // 角色提升为管理员的 step-up 门控 + userService *service.UserService } // NewUserHandler creates a new admin user handler @@ -38,12 +41,16 @@ func NewUserHandler( concurrencyService *service.ConcurrencyService, userPlatformQuotaRepo service.UserPlatformQuotaRepository, billingCache service.BillingCache, + totpService *service.TotpService, + userService *service.UserService, ) *UserHandler { return &UserHandler{ adminService: adminService, concurrencyService: concurrencyService, userPlatformQuotaRepo: userPlatformQuotaRepo, billingCache: billingCache, + totpService: totpService, + userService: userService, } } @@ -266,6 +273,13 @@ func (h *UserHandler) Create(c *gin.Context) { return } + // 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。 + if req.Role == service.RoleAdmin { + if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + return + } + } + user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{ Email: req.Email, Password: req.Password, @@ -308,6 +322,21 @@ func (h *UserHandler) Update(c *gin.Context) { return } + // 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。 + // 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。 + if req.Role == service.RoleAdmin { + target, err := h.adminService.GetUser(c.Request.Context(), userID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if target.Role != service.RoleAdmin { + if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + return + } + } + } + // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ Email: req.Email, @@ -577,6 +606,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) { response.Success(c, gin.H{"affected": affected}) } +// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users. +// POST /api/v1/admin/users/batch-limits +type BatchUpdateLimitsRequest struct { + UserIDs []int64 `json:"user_ids"` + All bool `json:"all"` + Concurrency *int `json:"concurrency" binding:"omitempty,min=0"` + RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"` +} + +func (h *UserHandler) BatchUpdateLimits(c *gin.Context) { + var req BatchUpdateLimitsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Concurrency == nil && req.RPMLimit == nil { + response.BadRequest(c, "at least one of concurrency or rpm_limit is required") + return + } + if !req.All && len(req.UserIDs) == 0 { + response.BadRequest(c, "user_ids is required unless all=true") + return + } + if !req.All && len(req.UserIDs) > 500 { + response.BadRequest(c, "user_ids cannot exceed 500") + return + } + + userIDs := req.UserIDs + if req.All { + userIDs = nil + page := 1 + const pageSize = 500 + for { + users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + if len(users) < pageSize { + break + } + page++ + } + } + + if len(userIDs) == 0 { + response.Success(c, gin.H{"affected": 0}) + return + } + + affected, err := h.adminService.BatchUpdateLimits( + c.Request.Context(), + userIDs, + req.Concurrency, + req.RPMLimit, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"affected": affected}) +} + // GetUserPlatformQuotas GET /admin/users/:id/platform-quotas // admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段 func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) { diff --git a/backend/internal/handler/admin/user_handler_activity_test.go b/backend/internal/handler/admin/user_handler_activity_test.go index a9fdc48aa..74a34de7c 100644 --- a/backend/internal/handler/admin/user_handler_activity_test.go +++ b/backend/internal/handler/admin/user_handler_activity_test.go @@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) @@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) diff --git a/backend/internal/handler/admin/user_handler_batch_limits_test.go b/backend/internal/handler/admin/user_handler_batch_limits_test.go new file mode 100644 index 000000000..a8250a991 --- /dev/null +++ b/backend/internal/handler/admin/user_handler_batch_limits_test.go @@ -0,0 +1,146 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type batchLimitsAdminServiceStub struct { + *stubAdminService + calls []batchLimitsAdminServiceCall +} + +type batchLimitsAdminServiceCall struct { + userIDs []int64 + concurrency *int + rpmLimit *int +} + +func cloneIntPointer(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + s.calls = append(s.calls, batchLimitsAdminServiceCall{ + userIDs: append([]int64(nil), userIDs...), + concurrency: cloneIntPointer(concurrency), + rpmLimit: cloneIntPointer(rpmLimit), + }) + return len(userIDs), nil +} + +func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewUserHandler(serviceStub, nil, nil, nil) + router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits) + return router +} + +func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/users/batch-limits", + bytes.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + return recorder +} + +func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) { + tests := []struct { + name string + body string + expectedConcurrency *int + expectedRPMLimit *int + }{ + {name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)}, + {name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)}, + {name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()} + recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, serviceStub.calls, 1) + require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs) + require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency) + require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit) + + var response struct { + Data struct { + Affected int `json:"affected"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, 2, response.Data.Affected) + }) + } +} + +func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) { + tooManyIDs := make([]int64, 501) + for index := range tooManyIDs { + tooManyIDs[index] = int64(index + 1) + } + tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10}) + require.NoError(t, err) + + tests := []struct { + name string + body []byte + }{ + {name: "no limits", body: []byte(`{"user_ids":[1]}`)}, + {name: "invalid json", body: []byte(`{"user_ids":`)}, + {name: "missing user ids", body: []byte(`{"rpm_limit":10}`)}, + {name: "more than 500 ids", body: tooManyBody}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()} + recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Empty(t, serviceStub.calls) + }) + } +} + +func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) { + base := newStubAdminService() + base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}} + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base} + recorder := postBatchLimits( + t, + setupBatchLimitsRouter(serviceStub), + []byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`), + ) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, serviceStub.calls, 1) + require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs) + require.Equal(t, 1, base.lastListUsers.calls) +} + +func pointerTo(value int) *int { + return &value +} diff --git a/backend/internal/handler/admin/user_handler_get_deleted_test.go b/backend/internal/handler/admin/user_handler_get_deleted_test.go index 1b3070cde..c915a88f8 100644 --- a/backend/internal/handler/admin/user_handler_get_deleted_test.go +++ b/backend/internal/handler/admin/user_handler_get_deleted_test.go @@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (* func setupGetByIDRouter(svc service.AdminService) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - h := NewUserHandler(svc, nil, nil, nil) + h := NewUserHandler(svc, nil, nil, nil, nil, nil) r.GET("/admin/users/:id", h.GetByID) return r } diff --git a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go index b5cdd9a18..0d14b6283 100644 --- a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go +++ b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go @@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) { t.Run(tc.name, func(t *testing.T) { stub := &listUsersFilterStub{AdminService: newStubAdminService()} r := gin.New() - h := NewUserHandler(stub, nil, nil, nil) + h := NewUserHandler(stub, nil, nil, nil, nil, nil) r.GET("/admin/users", h.List) w := httptest.NewRecorder() diff --git a/backend/internal/handler/admin/user_handler_role_stepup_test.go b/backend/internal/handler/admin/user_handler_role_stepup_test.go new file mode 100644 index 000000000..f4ffa3f65 --- /dev/null +++ b/backend/internal/handler/admin/user_handler_role_stepup_test.go @@ -0,0 +1,86 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 角色提升为管理员的 step-up 门控条件测试。 +// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止; +// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。 +func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + adminSvc := newStubAdminService() + // 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。 + adminSvc.users = append(adminSvc.users, service.User{ + ID: 2, + Email: "admin@example.com", + Role: service.RoleAdmin, + Status: service.StatusActive, + }) + + h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/users", h.Create) + router.PUT("/api/v1/admin/users/:id", h.Update) + return router, adminSvc +} + +func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + return rec +} + +func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"}) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"}) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"}) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestCreateAdminUserRequiresStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "email": "new-admin@example.com", "password": "pass123", "role": "admin", + }) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestCreateRegularUserSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "email": "new-user@example.com", "password": "pass123", "role": "user", + }) + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 4549fc129..5bbe1267e 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { panic("unexpected BatchAddConcurrency call") } +func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + panic("unexpected BatchUpdateLimits call") +} func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { return map[int64]*time.Time{}, nil diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index ba74a5869..7636fa5fd 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -49,6 +49,7 @@ type codexModelsFailoverHTTPUpstream struct { accountIDs []int64 firstErr error firstStatus int + firstBody string statuses map[int64]int } @@ -62,6 +63,14 @@ func (u *codexModelsFailoverHTTPUpstream) Do(_ *http.Request, _ string, accountI if u.firstErr != nil { return nil, u.firstErr } + if u.firstBody != "" && !hasStatus { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(u.firstBody)), + }, nil + } if !hasStatus { status = u.firstStatus } @@ -150,6 +159,22 @@ func TestCodexModelsFailsOverFromUpstreamTransportError(t *testing.T) { } } +func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusOK) + upstream.firstBody = `{"object":"list","data":[]}` + recorder := performCodexModelsRequest(t, handler, groupID) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { + t.Fatalf("body: got %q, want %q", got, want) + } +} + func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { statuses := []int{ http.StatusBadRequest, diff --git a/backend/internal/handler/totp_handler.go b/backend/internal/handler/totp_handler.go index 9d5527ca7..edd738ac0 100644 --- a/backend/internal/handler/totp_handler.go +++ b/backend/internal/handler/totp_handler.go @@ -159,7 +159,17 @@ func (h *TotpHandler) Disable(c *gin.Context) { // GetVerificationMethod returns the verification method for TOTP operations // GET /api/v1/user/totp/verification-method func (h *TotpHandler) GetVerificationMethod(c *gin.Context) { - method := h.totpService.GetVerificationMethod(c.Request.Context()) + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + method, err := h.totpService.GetVerificationMethod(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } response.Success(c, method) } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 2e366c230..1143449f1 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -93,6 +93,9 @@ func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index b159f8f5a..be8e0d552 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -48,7 +48,8 @@ const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + Beta // CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting -// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) +// HaikuBetaHeader Haiku 模型在 OAuth 真实客户端透传路径上的默认 anthropic-beta header。 +// OAuth mimic 路径统一使用 FullClaudeCodeMimicryBetas。 const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking // APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth) @@ -72,8 +73,8 @@ const CLICurrentVersion = "2.1.161" // 顺序与真实 CLI 抓包一致。 // // 使用建议: -// - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 -// - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 +// - OAuth mimic:所有模型(包括 Haiku)都使用这整份列表。 +// - OAuth 真实客户端透传:保留客户端 beta;未提供时使用模型对应默认值。 // - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 // - 不默认加入 redact-thinking,避免上游抹除 thinking 内容;客户端显式传入时由合并逻辑保留。 func FullClaudeCodeMimicryBetas() []string { diff --git a/backend/internal/pkg/xai/oauth.go b/backend/internal/pkg/xai/oauth.go index 1799415df..07d2227c2 100644 --- a/backend/internal/pkg/xai/oauth.go +++ b/backend/internal/pkg/xai/oauth.go @@ -41,7 +41,9 @@ const ( var ( oauthEndpointAllowedHosts = []string{"x.ai", "*.x.ai"} - baseURLAllowedHosts = []string{"api.x.ai", "cli-chat-proxy.grok.com"} + // *.api.x.ai 覆盖 xAI 区域端点(us-east-1/us-west-2/eu-west-1 等), + // 运营方可在端点间手动切换以规避单点不可用。 + baseURLAllowedHosts = []string{"api.x.ai", "*.api.x.ai", "cli-chat-proxy.grok.com"} ) // OAuthSession stores one PKCE OAuth flow. @@ -332,10 +334,17 @@ func normalizeKnownBaseURLPath(raw string) (string, error) { return strings.TrimRight(parsed.String(), "/"), nil } -// IsOfficialBaseURLHost 报告 host 是否属于官方 API / CLI 网关主机。 +// IsOfficialBaseURLHost 报告 host 是否属于官方 API / 区域 API / CLI 网关主机。 func IsOfficialBaseURLHost(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) for _, allowed := range baseURLAllowedHosts { + if strings.HasPrefix(allowed, "*.") { + suffix := strings.TrimPrefix(allowed, "*.") + if host == suffix || strings.HasSuffix(host, "."+suffix) { + return true + } + continue + } if host == allowed { return true } @@ -343,7 +352,18 @@ func IsOfficialBaseURLHost(host string) bool { return false } -// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai 或 CLI 网关), +// IsParseableBaseURL 报告 raw 是否能解析出 host。 +// 供读取路径判定存量脏数据:无法解析的值应回落默认端点,而不是把流量发往未定义目标。 +func IsParseableBaseURL(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + return err == nil && parsed.Host != "" +} + +// IsOfficialBaseURL 报告 raw 是否指向官方主机(api.x.ai / *.api.x.ai 区域端点 / CLI 网关), // 容忍存量凭证中的历史变体(大小写、显式 443 端口、百分号编码 path 等)。 // 无法解析的值一并视为官方,调用方据此回落默认端点而不是把流量发往未定义目标。 func IsOfficialBaseURL(raw string) bool { diff --git a/backend/internal/pkg/xai/oauth_test.go b/backend/internal/pkg/xai/oauth_test.go index 2a2dbf278..53c898310 100644 --- a/backend/internal/pkg/xai/oauth_test.go +++ b/backend/internal/pkg/xai/oauth_test.go @@ -211,12 +211,32 @@ func TestIsOfficialBaseURL(t *testing.T) { "https://relay.example.test/xai/v1", "http://relay.example.test/v1", "https://grok.com.evil.example.test/v1", + "https://api.x.ai.evil.example.test/v1", // 后缀伪装不属于 *.api.x.ai } for _, raw := range custom { require.False(t, IsOfficialBaseURL(raw), "expected custom: %q", raw) } } +func TestRegionalAPIEndpointsAreOfficialAndTrusted(t *testing.T) { + regional := []string{ + "https://us-east-1.api.x.ai/v1", + "https://us-west-2.api.x.ai/v1", + "https://eu-west-1.api.x.ai/v1", + } + for _, raw := range regional { + require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw) + + validated, err := ValidateTrustedBaseURL(raw) + require.NoError(t, err, "trusted validation should accept regional endpoint %q", raw) + require.Equal(t, raw, validated) + } + + // 区域端点作为官方主机同样强制 /v1 path + _, err := ValidateTrustedBaseURL("https://us-east-1.api.x.ai/other") + require.Error(t, err) +} + func TestValidateBaseURLsRejectEmptyQueryDelimiter(t *testing.T) { _, err := ValidateBaseURL("https://grok.example.test/v1?") require.Error(t, err) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 4c0edf72b..ece9aae77 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -928,6 +928,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { IsExclusive: g.IsExclusive, Status: g.Status, Hydrated: true, + DuplicateOperationID: derefString(g.DuplicateOperationID), SubscriptionType: g.SubscriptionType, DailyLimitUSD: g.DailyLimitUsd, WeeklyLimitUSD: g.WeeklyLimitUsd, diff --git a/backend/internal/repository/channel_monitor_duplicate_test.go b/backend/internal/repository/channel_monitor_duplicate_test.go new file mode 100644 index 000000000..6b189dfe3 --- /dev/null +++ b/backend/internal/repository/channel_monitor_duplicate_test.go @@ -0,0 +1,29 @@ +//go:build unit + +package repository + +import ( + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestChannelMonitorDuplicateOperationMetadataStaysOutOfRuntimeHeaders(t *testing.T) { + monitor := &service.ChannelMonitor{ + ExtraHeaders: map[string]string{"User-Agent": "Codex"}, + DuplicateOperationID: "operation-digest", + } + + persisted := channelMonitorHeadersForPersistence(monitor) + require.Equal(t, "operation-digest", persisted[service.ChannelMonitorDuplicateOperationIDMetadataKey]) + require.Equal(t, "Codex", persisted["User-Agent"]) + require.NotContains(t, monitor.ExtraHeaders, service.ChannelMonitorDuplicateOperationIDMetadataKey) + + restored := entToServiceMonitor(&dbent.ChannelMonitor{ExtraHeaders: persisted}) + require.Equal(t, "operation-digest", restored.DuplicateOperationID) + require.Equal(t, map[string]string{"User-Agent": "Codex"}, restored.ExtraHeaders) + require.NotContains(t, restored.ExtraHeaders, service.ChannelMonitorDuplicateOperationIDMetadataKey) + require.Equal(t, "operation-digest", persisted[service.ChannelMonitorDuplicateOperationIDMetadataKey], "decoding must not mutate the ent row") +} diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index c7b33f2bd..aa8b12ff8 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -12,6 +12,9 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" + + entsql "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqljson" ) // channelMonitorRepository 实现 service.ChannelMonitorRepository。 @@ -47,7 +50,7 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe SetIntervalSeconds(m.IntervalSeconds). SetJitterSeconds(m.JitterSeconds). SetCreatedBy(m.CreatedBy). - SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)). + SetExtraHeaders(channelMonitorHeadersForPersistence(m)). SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) if m.TemplateID != nil { builder = builder.SetTemplateID(*m.TemplateID) @@ -66,6 +69,30 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe return nil } +func (r *channelMonitorRepository) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.ChannelMonitor, error) { + if strings.TrimSpace(operationID) == "" { + return nil, nil + } + client := clientFromContext(ctx, r.client) + row, err := client.ChannelMonitor.Query(). + Where(func(selector *entsql.Selector) { + selector.Where(sqljson.ValueEQ( + channelmonitor.FieldExtraHeaders, + operationID, + sqljson.Path(service.ChannelMonitorDuplicateOperationIDMetadataKey), + )) + }). + Order(dbent.Asc(channelmonitor.FieldID)). + First(ctx) + if dbent.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find channel monitor duplicate operation: %w", err) + } + return entToServiceMonitor(row), nil +} + func (r *channelMonitorRepository) GetByID(ctx context.Context, id int64) (*service.ChannelMonitor, error) { row, err := r.client.ChannelMonitor.Query(). Where(channelmonitor.IDEQ(id)). @@ -90,7 +117,7 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe SetEnabled(m.Enabled). SetIntervalSeconds(m.IntervalSeconds). SetJitterSeconds(m.JitterSeconds). - SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)). + SetExtraHeaders(channelMonitorHeadersForPersistence(m)). SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) if m.TemplateID != nil { updater = updater.SetTemplateID(*m.TemplateID) @@ -704,30 +731,33 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor { if extras == nil { extras = []string{} } - headers := row.ExtraHeaders - if headers == nil { - headers = map[string]string{} + headers := make(map[string]string, len(row.ExtraHeaders)) + for key, value := range row.ExtraHeaders { + headers[key] = value } + duplicateOperationID := headers[service.ChannelMonitorDuplicateOperationIDMetadataKey] + delete(headers, service.ChannelMonitorDuplicateOperationIDMetadataKey) out := &service.ChannelMonitor{ - ID: row.ID, - Name: row.Name, - Provider: string(row.Provider), - APIMode: defaultAPIModeRepo(row.APIMode), - Endpoint: row.Endpoint, - APIKey: row.APIKeyEncrypted, // 仍为密文,service 层负责解密 - PrimaryModel: row.PrimaryModel, - ExtraModels: extras, - GroupName: row.GroupName, - Enabled: row.Enabled, - IntervalSeconds: row.IntervalSeconds, - JitterSeconds: row.JitterSeconds, - LastCheckedAt: row.LastCheckedAt, - CreatedBy: row.CreatedBy, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - ExtraHeaders: headers, - BodyOverrideMode: row.BodyOverrideMode, - BodyOverride: row.BodyOverride, + ID: row.ID, + Name: row.Name, + Provider: string(row.Provider), + APIMode: defaultAPIModeRepo(row.APIMode), + Endpoint: row.Endpoint, + APIKey: row.APIKeyEncrypted, // 仍为密文,service 层负责解密 + PrimaryModel: row.PrimaryModel, + ExtraModels: extras, + GroupName: row.GroupName, + Enabled: row.Enabled, + IntervalSeconds: row.IntervalSeconds, + JitterSeconds: row.JitterSeconds, + LastCheckedAt: row.LastCheckedAt, + CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + ExtraHeaders: headers, + BodyOverrideMode: row.BodyOverrideMode, + BodyOverride: row.BodyOverride, + DuplicateOperationID: duplicateOperationID, } if row.TemplateID != nil { id := *row.TemplateID @@ -736,6 +766,23 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor { return out } +func channelMonitorHeadersForPersistence(m *service.ChannelMonitor) map[string]string { + if m == nil { + return map[string]string{} + } + headers := make(map[string]string, len(m.ExtraHeaders)+1) + for key, value := range m.ExtraHeaders { + if key == service.ChannelMonitorDuplicateOperationIDMetadataKey { + continue + } + headers[key] = value + } + if operationID := strings.TrimSpace(m.DuplicateOperationID); operationID != "" { + headers[service.ChannelMonitorDuplicateOperationIDMetadataKey] = operationID + } + return headers +} + // emptyHeadersIfNilRepo 与 service.emptyHeadersIfNil 功能一致, // repo 独立一份避免 import 循环。 func emptyHeadersIfNilRepo(h map[string]string) map[string]string { diff --git a/backend/internal/repository/channel_monitor_template_duplicate_metadata_integration_test.go b/backend/internal/repository/channel_monitor_template_duplicate_metadata_integration_test.go new file mode 100644 index 000000000..d6d9ce4dd --- /dev/null +++ b/backend/internal/repository/channel_monitor_template_duplicate_metadata_integration_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/channelmonitor" + "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestApplyChannelMonitorTemplatePreservesDuplicateOperationMetadata(t *testing.T) { + tx := testEntTx(t) + ctx := dbent.NewTxContext(context.Background(), tx) + client := tx.Client() + + template, err := client.ChannelMonitorRequestTemplate.Create(). + SetName("duplicate-metadata-template"). + SetProvider(channelmonitorrequesttemplate.ProviderOpenai). + SetAPIMode(service.MonitorAPIModeResponses). + SetExtraHeaders(map[string]string{"User-Agent": "template-client"}). + SetBodyOverrideMode(service.MonitorBodyOverrideModeOff). + Save(ctx) + require.NoError(t, err) + + monitor, err := client.ChannelMonitor.Create(). + SetName("duplicate-copy"). + SetProvider(channelmonitor.ProviderOpenai). + SetAPIMode(service.MonitorAPIModeResponses). + SetEndpoint("https://api.example.com"). + SetAPIKeyEncrypted("encrypted-key"). + SetPrimaryModel("gpt-5.4-mini"). + SetIntervalSeconds(60). + SetCreatedBy(1). + SetTemplateID(template.ID). + SetExtraHeaders(map[string]string{ + "X-Original": "replaced", + service.ChannelMonitorDuplicateOperationIDMetadataKey: "operation-digest", + }). + Save(ctx) + require.NoError(t, err) + + repo := NewChannelMonitorRequestTemplateRepository(integrationEntClient, integrationDB) + affected, err := repo.ApplyToMonitors(ctx, template.ID, []int64{monitor.ID}) + require.NoError(t, err) + require.Equal(t, int64(1), affected) + + stored, err := client.ChannelMonitor.Get(ctx, monitor.ID) + require.NoError(t, err) + require.Equal(t, "template-client", stored.ExtraHeaders["User-Agent"]) + require.NotContains(t, stored.ExtraHeaders, "X-Original") + require.Equal(t, "operation-digest", stored.ExtraHeaders[service.ChannelMonitorDuplicateOperationIDMetadataKey]) + + runtimeMonitor := entToServiceMonitor(stored) + require.Equal(t, "operation-digest", runtimeMonitor.DuplicateOperationID) + require.NotContains(t, runtimeMonitor.ExtraHeaders, service.ChannelMonitorDuplicateOperationIDMetadataKey) +} diff --git a/backend/internal/repository/channel_monitor_template_duplicate_metadata_unit_test.go b/backend/internal/repository/channel_monitor_template_duplicate_metadata_unit_test.go new file mode 100644 index 000000000..18c566fa1 --- /dev/null +++ b/backend/internal/repository/channel_monitor_template_duplicate_metadata_unit_test.go @@ -0,0 +1,108 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" +) + +func TestApplyChannelMonitorTemplatePreservesDuplicateOperationMetadataAtomically(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + const templateID int64 = 7 + monitorIDs := []int64{41, 42} + + mock.ExpectBegin() + expectChannelMonitorTemplateForApply(mock, templateID) + mock.ExpectExec(`(?s)UPDATE "channel_monitors" SET "body_override" = NULL, "updated_at" = \$1, "api_mode" = \$2, "body_override_mode" = \$3 WHERE .*"template_id" = \$4.*"id" IN \(\$5, \$6\).*"provider" = \$7.*"api_mode" = \$8`). + WithArgs( + sqlmock.AnyArg(), + service.MonitorAPIModeResponses, + service.MonitorBodyOverrideModeOff, + templateID, + monitorIDs[0], + monitorIDs[1], + service.MonitorProviderOpenAI, + service.MonitorAPIModeResponses, + ). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(`(?s)UPDATE channel_monitors\s+SET extra_headers = \$1::jsonb \|\| CASE\s+WHEN COALESCE\(extra_headers, '\{\}'::jsonb\) \? \(\$2::text\)\s+THEN jsonb_build_object\(\$2::text, COALESCE\(extra_headers, '\{\}'::jsonb\) -> \(\$2::text\)\)\s+ELSE '\{\}'::jsonb\s+END\s+WHERE template_id = \$3\s+AND id = ANY\(\$4\)\s+AND provider = \$5\s+AND api_mode = \$6`). + WithArgs( + `{"User-Agent":"template-client"}`, + service.ChannelMonitorDuplicateOperationIDMetadataKey, + templateID, + `{41,42}`, + service.MonitorProviderOpenAI, + service.MonitorAPIModeResponses, + ). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + repo := NewChannelMonitorRequestTemplateRepository(client, db) + affected, err := repo.ApplyToMonitors(context.Background(), templateID, monitorIDs) + + require.NoError(t, err) + require.Equal(t, int64(2), affected) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApplyChannelMonitorTemplateRollsBackWhenHeaderRowCountDiffers(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + const templateID int64 = 7 + + mock.ExpectBegin() + expectChannelMonitorTemplateForApply(mock, templateID) + mock.ExpectExec(`(?s)UPDATE "channel_monitors" SET .*WHERE `). + WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectExec(`(?s)UPDATE channel_monitors\s+SET extra_headers = \$1::jsonb \|\| CASE.*jsonb_build_object\(\$2::text,.*WHERE template_id = \$3.*AND id = ANY\(\$4\)`). + WithArgs( + `{"User-Agent":"template-client"}`, + service.ChannelMonitorDuplicateOperationIDMetadataKey, + templateID, + `{41,42}`, + service.MonitorProviderOpenAI, + service.MonitorAPIModeResponses, + ). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectRollback() + + repo := NewChannelMonitorRequestTemplateRepository(client, db) + affected, err := repo.ApplyToMonitors(context.Background(), templateID, []int64{41, 42}) + + require.Zero(t, affected) + require.EqualError(t, err, "apply template headers: affected 1 rows, expected 2") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func expectChannelMonitorTemplateForApply(mock sqlmock.Sqlmock, templateID int64) { + now := time.Now() + mock.ExpectQuery(`(?s)SELECT .* FROM "channel_monitor_request_templates" WHERE "channel_monitor_request_templates"\."id" = \$1 LIMIT 2`). + WithArgs(templateID). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "created_at", "updated_at", "name", "provider", "api_mode", "description", + "extra_headers", "body_override_mode", "body_override", + }).AddRow( + templateID, now, now, "monitor-template", service.MonitorProviderOpenAI, + service.MonitorAPIModeResponses, "", []byte(`{"User-Agent":"template-client"}`), + service.MonitorBodyOverrideModeOff, nil, + )) +} diff --git a/backend/internal/repository/channel_monitor_template_repo.go b/backend/internal/repository/channel_monitor_template_repo.go index 3a972360f..e4886e1f8 100644 --- a/backend/internal/repository/channel_monitor_template_repo.go +++ b/backend/internal/repository/channel_monitor_template_repo.go @@ -3,12 +3,14 @@ package repository import ( "context" "database/sql" + "encoding/json" "fmt" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/channelmonitor" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" ) // channelMonitorRequestTemplateRepository 实现 service.ChannelMonitorRequestTemplateRepository。 @@ -110,12 +112,38 @@ func (r *channelMonitorRequestTemplateRepository) List(ctx context.Context, para // ApplyToMonitors 把模板当前配置覆盖到 monitorIDs 列表里的关联监控。 // WHERE 双重过滤:template_id = id AND id IN (monitorIDs),防止用户传了未关联本模板的 id -// 就被覆盖。走 ent UpdateMany 保留 hooks。 +// 就被覆盖。模板字段通过 ent UpdateMany 更新以保留 hooks;extra_headers 在同一事务中 +// 单独合并,以保留仅用于幂等恢复、绝不会发往上游的内部 operation ID。 func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) { if len(monitorIDs) == 0 { return 0, nil } - client := clientFromContext(ctx, r.client) + if tx := dbent.TxFromContext(ctx); tx != nil { + return r.applyToMonitorsWithClient(ctx, tx.Client(), id, monitorIDs) + } + + tx, err := r.client.Tx(ctx) + if err != nil { + return 0, fmt.Errorf("begin apply template transaction: %w", err) + } + txCtx := dbent.NewTxContext(ctx, tx) + affected, err := r.applyToMonitorsWithClient(txCtx, tx.Client(), id, monitorIDs) + if err != nil { + _ = tx.Rollback() + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit apply template transaction: %w", err) + } + return affected, nil +} + +func (r *channelMonitorRequestTemplateRepository) applyToMonitorsWithClient( + ctx context.Context, + client *dbent.Client, + id int64, + monitorIDs []int64, +) (int64, error) { tpl, err := client.ChannelMonitorRequestTemplate.Query(). Where(channelmonitorrequesttemplate.IDEQ(id)). Only(ctx) @@ -131,7 +159,6 @@ func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Co channelmonitor.APIModeEQ(defaultAPIModeRepo(tpl.APIMode)), ). SetAPIMode(defaultAPIModeRepo(tpl.APIMode)). - SetExtraHeaders(emptyHeadersIfNilRepo(tpl.ExtraHeaders)). SetBodyOverrideMode(defaultBodyModeRepo(tpl.BodyOverrideMode)) if tpl.BodyOverride != nil { updater = updater.SetBodyOverride(tpl.BodyOverride) @@ -143,7 +170,41 @@ func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Co if err != nil { return 0, fmt.Errorf("apply template to monitors: %w", err) } - return int64(affected), nil + if affected == 0 { + return 0, nil + } + + templateHeaders := channelMonitorHeadersForPersistence(&service.ChannelMonitor{ + ExtraHeaders: tpl.ExtraHeaders, + }) + templateHeadersJSON, err := json.Marshal(templateHeaders) + if err != nil { + return 0, fmt.Errorf("marshal template headers: %w", err) + } + result, err := client.ExecContext(ctx, ` + UPDATE channel_monitors + SET extra_headers = $1::jsonb || CASE + WHEN COALESCE(extra_headers, '{}'::jsonb) ? ($2::text) + THEN jsonb_build_object($2::text, COALESCE(extra_headers, '{}'::jsonb) -> ($2::text)) + ELSE '{}'::jsonb + END + WHERE template_id = $3 + AND id = ANY($4) + AND provider = $5 + AND api_mode = $6 + `, string(templateHeadersJSON), service.ChannelMonitorDuplicateOperationIDMetadataKey, + id, pq.Array(monitorIDs), string(tpl.Provider), defaultAPIModeRepo(tpl.APIMode)) + if err != nil { + return 0, fmt.Errorf("apply template headers to monitors: %w", err) + } + headersAffected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count applied template headers: %w", err) + } + if headersAffected != int64(affected) { + return 0, fmt.Errorf("apply template headers: affected %d rows, expected %d", headersAffected, affected) + } + return headersAffected, nil } // CountAssociatedMonitors 统计关联监控数(UI 展示「N 个配置」用)。 diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 47efb72dd..c749ec47b 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -32,12 +32,31 @@ func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupReposi return newGroupRepositoryWithSQL(client, sqlDB) } +// NewAdminGroupRepository exposes the atomic group-duplication capability as +// an explicit dependency of the admin service. +func NewAdminGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.AdminGroupRepository { + return newGroupRepositoryWithSQL(client, sqlDB) +} + func newGroupRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *groupRepository { return &groupRepository{client: client, sql: sqlq} } func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) error { - builder := r.client.Group.Create(). + if err := createGroupRecord(ctx, r.client, groupIn); err != nil { + return err + } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { + logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err) + } + return nil +} + +func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *service.Group) error { + if groupIn == nil { + return errors.New("group is nil") + } + builder := client.Group.Create(). SetName(groupIn.Name). SetDescription(groupIn.Description). SetPlatform(groupIn.Platform). @@ -81,6 +100,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetPeakStart(groupIn.PeakStart). SetPeakEnd(groupIn.PeakEnd). SetPeakRateMultiplier(groupIn.PeakRateMultiplier) + if groupIn.DuplicateOperationID != "" { + builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID) + } // 设置模型路由配置 if groupIn.ModelRouting != nil { @@ -91,15 +113,87 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) created, err := builder.Save(ctx) + if err != nil { + return translatePersistenceError(err, nil, service.ErrGroupExists) + } + groupIn.ID = created.ID + groupIn.CreatedAt = created.CreatedAt + groupIn.UpdatedAt = created.UpdatedAt + return nil +} + +func (r *groupRepository) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) { + operationID = strings.TrimSpace(operationID) + if operationID == "" { + return nil, nil + } + row, err := r.client.Group.Query(). + Where(group.DuplicateOperationIDEQ(operationID)). + Order(dbent.Asc(group.FieldID)). + First(ctx) + if dbent.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("find group duplicate operation: %w", err) + } + return groupEntityToService(row), nil +} + +// CreateFromSource atomically persists a copied group, clones the source +// account bindings with their exact priorities, and writes its scheduler event. +func (r *groupRepository) CreateFromSource(ctx context.Context, groupIn *service.Group, sourceGroupID int64) error { + if groupIn == nil { + return errors.New("group is nil") + } + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + + var txClient *dbent.Client if err == nil { - groupIn.ID = created.ID - groupIn.CreatedAt = created.CreatedAt - groupIn.UpdatedAt = created.UpdatedAt - if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { - logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err) + defer func() { _ = tx.Rollback() }() + txClient = tx.Client() + } else { + // Reuse a caller-owned transaction when this repository is already transactional. + txClient = r.client + } + + if err := createGroupRecord(ctx, txClient, groupIn); err != nil { + return err + } + result, err := txClient.ExecContext( + ctx, + `INSERT INTO account_groups (account_id, group_id, priority, created_at) + SELECT ag.account_id, $2, ag.priority, NOW() + FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1 + AND a.deleted_at IS NULL + AND (NOT $3 OR a.type <> $4) + ON CONFLICT (account_id, group_id) DO NOTHING`, + sourceGroupID, + groupIn.ID, + groupIn.RequireOAuthOnly, + service.AccountTypeAPIKey, + ) + if err != nil { + return err + } + if count, countErr := result.RowsAffected(); countErr == nil { + groupIn.AccountCount = count + } + if err := enqueueSchedulerOutbox(ctx, txClient, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil { + return err + } + + if tx != nil { + if err := tx.Commit(); err != nil { + return err } } - return translatePersistenceError(err, nil, service.ErrGroupExists) + return nil } func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group, error) { diff --git a/backend/internal/repository/group_repo_duplicate_integration_test.go b/backend/internal/repository/group_repo_duplicate_integration_test.go new file mode 100644 index 000000000..4748410d7 --- /dev/null +++ b/backend/internal/repository/group_repo_duplicate_integration_test.go @@ -0,0 +1,91 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestCreateGroupFromSourceRollsBackWhenOutboxInsertFails(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := newGroupRepositoryWithSQL(client, integrationDB) + suffix := time.Now().UnixNano() + operationID := strings.Repeat("b", 64) + + source, err := client.Group.Create(). + SetName(fmt.Sprintf("duplicate-rollback-source-%d", suffix)). + SetPlatform(service.PlatformAnthropic). + Save(ctx) + require.NoError(t, err) + account, err := client.Account.Create(). + SetName(fmt.Sprintf("duplicate-rollback-account-%d", suffix)). + SetPlatform(service.PlatformAnthropic). + SetType(service.AccountTypeOAuth). + Save(ctx) + require.NoError(t, err) + _, err = client.AccountGroup.Create(). + SetAccountID(account.ID). + SetGroupID(source.ID). + SetPriority(29). + Save(ctx) + require.NoError(t, err) + + functionName := fmt.Sprintf("fail_group_duplicate_outbox_%d", suffix) + triggerName := fmt.Sprintf("fail_group_duplicate_outbox_trigger_%d", suffix) + _, err = integrationDB.ExecContext(ctx, fmt.Sprintf(` + CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.group_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM groups + WHERE id = NEW.group_id AND duplicate_operation_id = '%s' + ) THEN + RAISE EXCEPTION 'forced duplicate outbox failure'; + END IF; + RETURN NEW; + END; + $$`, functionName, operationID)) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, fmt.Sprintf( + "CREATE TRIGGER %s BEFORE INSERT ON scheduler_outbox FOR EACH ROW EXECUTE FUNCTION %s()", + triggerName, + functionName, + )) + require.NoError(t, err) + + duplicateName := fmt.Sprintf("duplicate-rollback-copy-%d", suffix) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON scheduler_outbox", triggerName)) + _, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP FUNCTION IF EXISTS %s()", functionName)) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM scheduler_outbox WHERE group_id = $1", source.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE account_id = $1", account.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", account.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE name IN ($1, $2)", source.Name, duplicateName) + }) + + duplicate := &service.Group{ + Name: duplicateName, + Platform: source.Platform, + RateMultiplier: 1, + Status: "inactive", + SubscriptionType: service.SubscriptionTypeStandard, + DuplicateOperationID: operationID, + } + err = repo.CreateFromSource(ctx, duplicate, source.ID) + require.ErrorContains(t, err, "forced duplicate outbox failure") + + var groupCount, bindingCount, outboxCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM groups WHERE name = $1", duplicateName).Scan(&groupCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", duplicate.ID).Scan(&bindingCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1", duplicate.ID).Scan(&outboxCount)) + require.Zero(t, groupCount) + require.Zero(t, bindingCount) + require.Zero(t, outboxCount) +} diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 68183b2b7..8c1450477 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "strings" "testing" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -67,6 +68,91 @@ func (s *GroupRepoSuite) TestCreate() { s.Require().Equal("test-create", got.Name) } +func (s *GroupRepoSuite) TestCreateFromSourcePreservesPriorityAndFiltersIneligibleAccounts() { + source := &service.Group{ + Name: "duplicate-source", + Platform: service.PlatformOpenAI, + RateMultiplier: 1, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + RequireOAuthOnly: true, + } + s.Require().NoError(s.repo.Create(s.ctx, source)) + + insertAccount := func(name, accountType string, deleted bool) int64 { + var id int64 + deletedAt := any(nil) + if deleted { + deletedAt = "2026-07-16T00:00:00Z" + } + s.Require().NoError(scanSingleRow( + s.ctx, + s.tx, + "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, $4) RETURNING id", + []any{name, service.PlatformOpenAI, accountType, deletedAt}, + &id, + )) + return id + } + oauthID := insertAccount("duplicate-oauth", service.AccountTypeOAuth, false) + apiKeyID := insertAccount("duplicate-apikey", service.AccountTypeAPIKey, false) + deletedID := insertAccount("duplicate-deleted", service.AccountTypeOAuth, true) + for _, binding := range []struct { + accountID int64 + priority int + }{{oauthID, 37}, {apiKeyID, 8}, {deletedID, 3}} { + _, err := s.tx.ExecContext( + s.ctx, + "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, $3, NOW())", + binding.accountID, + source.ID, + binding.priority, + ) + s.Require().NoError(err) + } + + duplicate := &service.Group{ + Name: "duplicate-source (Copy)", + Platform: source.Platform, + RateMultiplier: source.RateMultiplier, + Status: "inactive", + SubscriptionType: source.SubscriptionType, + RequireOAuthOnly: true, + DuplicateOperationID: strings.Repeat("a", 64), + } + s.Require().NoError(s.repo.CreateFromSource(s.ctx, duplicate, source.ID)) + s.Require().EqualValues(1, duplicate.AccountCount) + + rows, err := s.tx.QueryContext( + s.ctx, + "SELECT account_id, priority FROM account_groups WHERE group_id = $1 ORDER BY account_id", + duplicate.ID, + ) + s.Require().NoError(err) + defer func() { _ = rows.Close() }() + s.Require().True(rows.Next()) + var copiedAccountID int64 + var copiedPriority int + s.Require().NoError(rows.Scan(&copiedAccountID, &copiedPriority)) + s.Require().Equal(oauthID, copiedAccountID) + s.Require().Equal(37, copiedPriority) + s.Require().False(rows.Next(), "API-key and soft-deleted accounts must not be copied") + + recovered, err := s.repo.FindByDuplicateOperationID(s.ctx, duplicate.DuplicateOperationID) + s.Require().NoError(err) + s.Require().Equal(duplicate.ID, recovered.ID) + + var outboxCount int + s.Require().NoError(scanSingleRow( + s.ctx, + s.tx, + "SELECT COUNT(*) FROM scheduler_outbox WHERE group_id = $1", + []any{duplicate.ID}, + &outboxCount, + )) + s.Require().Equal(1, outboxCount) +} + func (s *GroupRepoSuite) TestGetByID_NotFound() { _, err := s.repo.GetByID(s.ctx, 999999) s.Require().Error(err, "expected error for non-existent ID") diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d4c0d0dc2..e739ab1f1 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -867,6 +867,39 @@ func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int6 return int(affected), nil } +func (r *userRepository) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + if len(userIDs) == 0 || (concurrency == nil && rpmLimit == nil) { + return 0, nil + } + + setClauses := make([]string, 0, 3) + args := make([]any, 0, 3) + if concurrency != nil { + value := max(*concurrency, 0) + args = append(args, value) + setClauses = append(setClauses, fmt.Sprintf("concurrency = $%d", len(args))) + } + if rpmLimit != nil { + value := max(*rpmLimit, 0) + args = append(args, value) + setClauses = append(setClauses, fmt.Sprintf("rpm_limit = $%d", len(args))) + } + setClauses = append(setClauses, "updated_at = NOW()") + args = append(args, pq.Array(userIDs)) + + query := fmt.Sprintf( + "UPDATE users SET %s WHERE id = ANY($%d) AND deleted_at IS NULL", + strings.Join(setClauses, ", "), + len(args), + ) + res, err := r.sql.ExecContext(ctx, query, args...) + if err != nil { + return 0, fmt.Errorf("batch update user limits: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index 42d10af63..1b970b952 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -161,6 +161,60 @@ func (s *UserRepoSuite) TestUpdate() { s.Require().Equal("updated", updated.Username) } +func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesOnlyProvidedFields() { + user := s.mustCreateUser(&service.User{ + Email: "batch-limits-one-field@test.com", + Concurrency: 4, + RPMLimit: 20, + }) + concurrency := 9 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, nil) + s.Require().NoError(err) + s.Equal(1, affected) + + updated, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Equal(9, updated.Concurrency) + s.Equal(20, updated.RPMLimit) +} + +func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesBothFieldsToZero() { + user := s.mustCreateUser(&service.User{ + Email: "batch-limits-zero@test.com", + Concurrency: 4, + RPMLimit: 20, + }) + zero := 0 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &zero, &zero) + s.Require().NoError(err) + s.Equal(1, affected) + + updated, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Zero(updated.Concurrency) + s.Zero(updated.RPMLimit) +} + +func (s *UserRepoSuite) TestBatchUpdateLimitsIgnoresDeletedUsersAndReturnsAffectedRows() { + active := s.mustCreateUser(&service.User{Email: "batch-limits-active@test.com", RPMLimit: 10}) + deleted := s.mustCreateUser(&service.User{Email: "batch-limits-deleted@test.com", RPMLimit: 10}) + s.Require().NoError(s.client.User.DeleteOneID(deleted.ID).Exec(s.ctx)) + rpmLimit := 45 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{active.ID, deleted.ID}, nil, &rpmLimit) + s.Require().NoError(err) + s.Equal(1, affected) + + updatedActive, err := s.repo.GetByID(s.ctx, active.ID) + s.Require().NoError(err) + s.Equal(45, updatedActive.RPMLimit) + updatedDeleted, err := s.repo.GetByIDIncludeDeleted(s.ctx, deleted.ID) + s.Require().NoError(err) + s.Equal(10, updatedDeleted.RPMLimit) +} + func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() { user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"}) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 1bcb8e6a2..581831912 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet( NewUserRepository, NewAPIKeyRepository, NewGroupRepository, + NewAdminGroupRepository, NewAccountRepository, NewAdminAccountRepository, NewScheduledTestPlanRepository, // 定时测试计划仓储 diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3fb1cbff8..955e9df1b 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1557,6 +1557,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (r *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { return false, errors.New("not implemented") @@ -1731,6 +1734,14 @@ func (stubGroupRepo) UpdateSortOrders(ctx context.Context, updates []service.Gro return nil } +func (stubGroupRepo) FindByDuplicateOperationID(ctx context.Context, operationID string) (*service.Group, error) { + return nil, nil +} + +func (stubGroupRepo) CreateFromSource(ctx context.Context, group *service.Group, sourceGroupID int64) error { + return errors.New("not implemented") +} + type stubAccountRepo struct { bulkUpdateIDs []int64 } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index af24f42e1..3c844c689 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -200,6 +200,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go index 46fd82e8b..b8ffc3d07 100644 --- a/backend/internal/server/middleware/step_up.go +++ b/backend/internal/server/middleware/step_up.go @@ -45,42 +45,56 @@ func NewStepUpAuthMiddleware(totpService *service.TotpService, userService *serv func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gin.HandlerFunc { return func(c *gin.Context) { - if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { - AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", - "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + if !enforceStepUp(c, grantChecker, userReader) { return } - - subject, ok := GetAuthSubjectFromContext(c) - if !ok || subject.UserID <= 0 { - AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") - return - } - - user, err := userReader.GetByID(c.Request.Context(), subject.UserID) - if err != nil { - AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") - return - } - if !user.TotpEnabled { - AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED", - "This operation requires two-factor authentication; please enable TOTP first") - return - } - - sessionKey := StepUpSessionKey(c, subject.UserID) - granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey) - if err != nil { - // 安全门控故障时选择 fail-closed。 - AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable") - return - } - if !granted { - AbortWithError(c, 403, "STEP_UP_REQUIRED", - "This operation requires recent two-factor verification") - return - } - c.Next() } } + +// EnforceStepUp 对当前请求执行与 StepUpAuthMiddleware 相同语义的 step-up 门控, +// 供 handler 在需要按请求内容条件触发时调用(如仅当把用户角色提升为管理员时)。 +// 校验失败时写入错误响应并中止请求,返回 false;通过返回 true。 +func EnforceStepUp(c *gin.Context, totpService *service.TotpService, userService *service.UserService) bool { + return enforceStepUp(c, totpService, userService) +} + +func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader) bool { + if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", + "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + return false + } + + subject, ok := GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") + return false + } + + user, err := userReader.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") + return false + } + if !user.TotpEnabled { + AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED", + "This operation requires two-factor authentication; please enable TOTP first") + return false + } + + sessionKey := StepUpSessionKey(c, subject.UserID) + granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey) + if err != nil { + // 安全门控故障时选择 fail-closed。 + AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable") + return false + } + if !granted { + AbortWithError(c, 403, "STEP_UP_REQUIRED", + "This operation requires recent two-factor verification") + return false + } + + return true +} diff --git a/backend/internal/server/middleware/step_up_test.go b/backend/internal/server/middleware/step_up_test.go new file mode 100644 index 000000000..557d94d04 --- /dev/null +++ b/backend/internal/server/middleware/step_up_test.go @@ -0,0 +1,105 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type stubStepUpGrantChecker struct { + granted bool + err error +} + +func (s stubStepUpGrantChecker) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) { + return s.granted, s.err +} + +type stubStepUpUserReader struct { + user *service.User + err error +} + +func (s stubStepUpUserReader) GetByID(ctx context.Context, id int64) (*service.User, error) { + return s.user, s.err +} + +func newStepUpTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/sensitive", nil) + return c, rec +} + +func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + + require.False(t, ok) + require.True(t, c.IsAborted()) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") +} + +func TestEnforceStepUpRequiresAuthSubject(t *testing.T) { + c, rec := newStepUpTestContext(t) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestEnforceStepUpRequiresTotpEnabled(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}}) + + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_TOTP_NOT_ENABLED") +} + +func TestEnforceStepUpFailsClosedOnGrantError(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{err: errors.New("redis down")}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_UNAVAILABLE") +} + +func TestEnforceStepUpRequiresGrant(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_REQUIRED") +} + +func TestEnforceStepUpPassesWithGrant(t *testing.T) { + c, _ := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.True(t, ok) + require.False(t, c.IsAborted()) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 94867bf59..01df7b653 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -275,6 +275,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus) users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency) + users.POST("/batch-limits", h.Admin.User.BatchUpdateLimits) users.GET("/:id/platform-quotas", h.Admin.User.GetUserPlatformQuotas) users.PUT("/:id/platform-quotas", h.Admin.User.UpdateUserPlatformQuotas) users.POST("/:id/platform-quotas/reset", h.Admin.User.ResetUserPlatformQuotaWindow) @@ -296,6 +297,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates) groups.GET("/:id", h.Admin.Group.GetByID) groups.POST("", h.Admin.Group.Create) + groups.POST("/:id/duplicate", h.Admin.Group.Duplicate) groups.PUT("/:id", h.Admin.Group.Update) groups.DELETE("/:id", h.Admin.Group.Delete) groups.GET("/:id/stats", h.Admin.Group.GetStats) @@ -682,6 +684,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) { monitors.GET("", h.Admin.ChannelMonitor.List) monitors.POST("", h.Admin.ChannelMonitor.Create) monitors.GET("/:id", h.Admin.ChannelMonitor.Get) + monitors.POST("/:id/duplicate", h.Admin.ChannelMonitor.Duplicate) monitors.PUT("/:id", h.Admin.ChannelMonitor.Update) monitors.DELETE("/:id", h.Admin.ChannelMonitor.Delete) monitors.POST("/:id/run", h.Admin.ChannelMonitor.Run) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 5e530601d..701e14d74 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1281,11 +1281,12 @@ func (a *Account) GetGrokBaseURL() string { } baseURL := strings.TrimSpace(a.GetCredential("base_url")) if a.IsGrokOAuth() { - // Subscription traffic defaults to the supported CLI gateway. Stored - // official-host values (written by credential creation/refresh, or - // legacy variants) mean "not customized"; only an explicit custom-host - // forwarding address redirects traffic. - if baseURL == "" || xai.IsOfficialBaseURL(baseURL) { + // Operators switch subscription traffic between the official CLI + // gateway, the official/regional API hosts and third-party relays + // (individual endpoints go down from time to time), so a stored + // value is always honored as-is. Only empty or unparseable values + // fall back to the default CLI gateway. + if baseURL == "" || !xai.IsParseableBaseURL(baseURL) { return xai.DefaultCLIBaseURL } return baseURL @@ -1297,21 +1298,20 @@ func (a *Account) GetGrokBaseURL() string { } // GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs. -// OAuth text traffic uses the subscription CLI proxy, but large image and -// video payloads must use api.x.ai because the CLI proxy enforces a smaller -// request-body limit. Explicit custom forwarding hosts remain operator-owned. +// The subscription CLI gateway enforces a small request-body limit that +// rejects large Base64 media payloads, so OAuth media leaves for api.x.ai +// whenever text traffic resolves to the CLI gateway. Every other manually +// selected endpoint (official/regional API hosts or custom relays) serves +// media as-is. func (a *Account) GetGrokMediaBaseURL() string { if !a.IsGrok() { return "" } - if a.IsGrokOAuth() { - baseURL := strings.TrimSpace(a.GetCredential("base_url")) - if baseURL == "" || xai.IsOfficialBaseURL(baseURL) { - return xai.DefaultBaseURL - } - return baseURL + baseURL := a.GetGrokBaseURL() + if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) { + return xai.DefaultBaseURL } - return a.GetGrokBaseURL() + return baseURL } func (a *Account) GetGrokAccessToken() string { diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go index 9f5a1a7d6..2d6ead709 100644 --- a/backend/internal/service/account_base_url_test.go +++ b/backend/internal/service/account_base_url_test.go @@ -178,7 +178,7 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) { expected: xai.DefaultCLIBaseURL, }, { - name: "oauth legacy API default is migrated at runtime to CLI subscription proxy", + name: "oauth stored official API endpoint is honored (manual endpoint switch)", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, @@ -186,81 +186,37 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) { "base_url": xai.DefaultBaseURL, }, }, - expected: xai.DefaultCLIBaseURL, + expected: xai.DefaultBaseURL, }, { - name: "oauth legacy API default with trailing slash is migrated at runtime", + name: "oauth stored regional API endpoint is honored", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, Credentials: map[string]any{ - "base_url": xai.DefaultBaseURL + "/", + "base_url": "https://us-west-2.api.x.ai/v1", + }, + }, + expected: "https://us-west-2.api.x.ai/v1", + }, + { + name: "oauth stored CLI proxy is honored verbatim", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": xai.DefaultCLIBaseURL, }, }, expected: xai.DefaultCLIBaseURL, }, { - name: "oauth legacy API root is migrated at runtime", + name: "oauth unparseable base_url falls back to CLI proxy", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, Credentials: map[string]any{ - "base_url": "https://api.x.ai", - }, - }, - expected: xai.DefaultCLIBaseURL, - }, - { - name: "oauth legacy API root with canonical HTTPS port is migrated at runtime", - account: Account{ - Type: AccountTypeOAuth, - Platform: PlatformGrok, - Credentials: map[string]any{ - "base_url": "HTTPS://API.X.AI:443/", - }, - }, - expected: xai.DefaultCLIBaseURL, - }, - { - name: "oauth legacy API canonical port with leading zeroes is migrated at runtime", - account: Account{ - Type: AccountTypeOAuth, - Platform: PlatformGrok, - Credentials: map[string]any{ - "base_url": "https://api.x.ai:0443/v1", - }, - }, - expected: xai.DefaultCLIBaseURL, - }, - { - name: "oauth legacy API encoded version path is migrated at runtime", - account: Account{ - Type: AccountTypeOAuth, - Platform: PlatformGrok, - Credentials: map[string]any{ - "base_url": "https://api.x.ai/%76%31", - }, - }, - expected: xai.DefaultCLIBaseURL, - }, - { - name: "oauth legacy API encoded trailing slash is migrated at runtime", - account: Account{ - Type: AccountTypeOAuth, - Platform: PlatformGrok, - Credentials: map[string]any{ - "base_url": "https://api.x.ai/v1%2F", - }, - }, - expected: xai.DefaultCLIBaseURL, - }, - { - name: "oauth non-default API port remains pinned to CLI proxy", - account: Account{ - Type: AccountTypeOAuth, - Platform: PlatformGrok, - Credentials: map[string]any{ - "base_url": "https://api.x.ai:8443/v1", + "base_url": "not a url", }, }, expected: xai.DefaultCLIBaseURL, @@ -318,7 +274,7 @@ func TestGetGrokBaseURLHonorsOAuthCustomRegardlessOfUnsafeOverrides(t *testing.T require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL()) } -func TestGetGrokMediaBaseURLUsesOfficialAPIForOAuthMedia(t *testing.T) { +func TestGetGrokMediaBaseURLRedirectsCLIGatewayToOfficialAPI(t *testing.T) { tests := []struct { name string account Account @@ -356,7 +312,18 @@ func TestGetGrokMediaBaseURLUsesOfficialAPIForOAuthMedia(t *testing.T) { expected: xai.DefaultBaseURL, }, { - name: "oauth legacy official API stays on the media API", + name: "oauth unparseable base_url falls back to official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "not a url", + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth stored official API endpoint is honored (manual endpoint switch)", account: Account{ Type: AccountTypeOAuth, Platform: PlatformGrok, @@ -366,6 +333,17 @@ func TestGetGrokMediaBaseURLUsesOfficialAPIForOAuthMedia(t *testing.T) { }, expected: xai.DefaultBaseURL, }, + { + name: "oauth stored regional API endpoint is honored for media", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://us-west-2.api.x.ai/v1", + }, + }, + expected: "https://us-west-2.api.x.ai/v1", + }, { name: "oauth custom base_url redirects media traffic", account: Account{ diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go new file mode 100644 index 000000000..ef025e3c8 --- /dev/null +++ b/backend/internal/service/admin_group_duplicate.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +const ( + maxGroupNameRunes = 100 + duplicateGroupInactiveStatus = "inactive" +) + +func duplicateGroupOperationID(sourceID int64, actorScope, operationKey string) string { + operationKey = strings.TrimSpace(operationKey) + if operationKey == "" { + return "" + } + actorScope = strings.TrimSpace(actorScope) + if actorScope == "" { + actorScope = "admin:0" + } + payload := "admin.groups.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey + digest := sha256.Sum256([]byte(payload)) + return fmt.Sprintf("%x", digest) +} + +func duplicateGroupName(sourceName string, copyNumber int) string { + if copyNumber < 1 { + copyNumber = 1 + } + suffix := " (Copy)" + if copyNumber > 1 { + suffix = fmt.Sprintf(" (Copy %d)", copyNumber) + } + baseRunes := []rune(strings.TrimSpace(sourceName)) + maxBaseRunes := maxGroupNameRunes - len([]rune(suffix)) + if maxBaseRunes < 0 { + maxBaseRunes = 0 + } + if len(baseRunes) > maxBaseRunes { + baseRunes = baseRunes[:maxBaseRunes] + } + return string(baseRunes) + suffix +} + +func cloneGroupValuePointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneGroupModelRouting(value map[string][]int64) map[string][]int64 { + if value == nil { + return nil + } + cloned := make(map[string][]int64, len(value)) + for model, accountIDs := range value { + cloned[model] = append([]int64(nil), accountIDs...) + } + return cloned +} + +func cloneGroupMessagesDispatchModelConfig(value OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig { + cloned := value + if value.ExactModelMappings != nil { + cloned.ExactModelMappings = make(map[string]string, len(value.ExactModelMappings)) + for requestedModel, mappedModel := range value.ExactModelMappings { + cloned.ExactModelMappings[requestedModel] = mappedModel + } + } + return cloned +} + +func cloneGroupForDuplicate(source *Group, operationID string) *Group { + return &Group{ + Name: duplicateGroupName(source.Name, 1), + Description: source.Description, + Platform: source.Platform, + RateMultiplier: source.RateMultiplier, + PeakRateEnabled: source.PeakRateEnabled, + PeakStart: source.PeakStart, + PeakEnd: source.PeakEnd, + PeakRateMultiplier: source.PeakRateMultiplier, + IsExclusive: source.IsExclusive, + Status: duplicateGroupInactiveStatus, + DuplicateOperationID: operationID, + SubscriptionType: source.SubscriptionType, + DailyLimitUSD: cloneGroupValuePointer(source.DailyLimitUSD), + WeeklyLimitUSD: cloneGroupValuePointer(source.WeeklyLimitUSD), + MonthlyLimitUSD: cloneGroupValuePointer(source.MonthlyLimitUSD), + DefaultValidityDays: source.DefaultValidityDays, + AllowImageGeneration: source.AllowImageGeneration, + AllowBatchImageGeneration: source.AllowBatchImageGeneration, + ImageRateIndependent: source.ImageRateIndependent, + ImageRateMultiplier: source.ImageRateMultiplier, + ImagePrice1K: cloneGroupValuePointer(source.ImagePrice1K), + ImagePrice2K: cloneGroupValuePointer(source.ImagePrice2K), + ImagePrice4K: cloneGroupValuePointer(source.ImagePrice4K), + BatchImageDiscountMultiplier: source.BatchImageDiscountMultiplier, + BatchImageHoldMultiplier: source.BatchImageHoldMultiplier, + VideoRateIndependent: source.VideoRateIndependent, + VideoRateMultiplier: source.VideoRateMultiplier, + VideoPrice480P: cloneGroupValuePointer(source.VideoPrice480P), + VideoPrice720P: cloneGroupValuePointer(source.VideoPrice720P), + VideoPrice1080P: cloneGroupValuePointer(source.VideoPrice1080P), + WebSearchPricePerCall: cloneGroupValuePointer(source.WebSearchPricePerCall), + ClaudeCodeOnly: source.ClaudeCodeOnly, + FallbackGroupID: cloneGroupValuePointer(source.FallbackGroupID), + FallbackGroupIDOnInvalidRequest: cloneGroupValuePointer(source.FallbackGroupIDOnInvalidRequest), + ModelRouting: cloneGroupModelRouting(source.ModelRouting), + ModelRoutingEnabled: source.ModelRoutingEnabled, + MCPXMLInject: source.MCPXMLInject, + SupportedModelScopes: append([]string(nil), source.SupportedModelScopes...), + SortOrder: source.SortOrder, + AllowMessagesDispatch: source.AllowMessagesDispatch, + RequireOAuthOnly: source.RequireOAuthOnly, + RequirePrivacySet: source.RequirePrivacySet, + DefaultMappedModel: source.DefaultMappedModel, + MessagesDispatchModelConfig: cloneGroupMessagesDispatchModelConfig(source.MessagesDispatchModelConfig), + ModelsListConfig: GroupModelsListConfig{ + Enabled: source.ModelsListConfig.Enabled, + Models: append([]string(nil), source.ModelsListConfig.Models...), + }, + RPMLimit: source.RPMLimit, + } +} + +// RecoverDuplicateGroup performs a read-only lookup for a copy that was already +// committed for the same actor, source group, and idempotency key. +func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + operationID := duplicateGroupOperationID(id, actorScope, operationKey) + if operationID == "" { + return nil, nil + } + if s.groupDuplicateRepo == nil { + return nil, errors.New("group duplicate repository is not configured") + } + group, err := s.groupDuplicateRepo.FindByDuplicateOperationID(ctx, operationID) + if err != nil { + return nil, fmt.Errorf("find duplicate group operation: %w", err) + } + if group == nil { + return nil, nil + } + hydrated, err := s.groupRepo.GetByID(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("load recovered duplicate group: %w", err) + } + return hydrated, nil +} + +// DuplicateGroup creates an inactive copy of a group's configuration and exact +// account priorities. The repository commits the group, bindings, and outbox +// event atomically so a failed binding never leaves an orphan group. +func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + source, err := s.groupRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if s.groupDuplicateRepo == nil { + return nil, errors.New("group duplicate repository is not configured") + } + + duplicate := cloneGroupForDuplicate(source, duplicateGroupOperationID(id, actorScope, operationKey)) + for copyNumber := 1; ; copyNumber++ { + duplicate.Name = duplicateGroupName(source.Name, copyNumber) + duplicate.ID = 0 + duplicate.CreatedAt = time.Time{} + duplicate.UpdatedAt = time.Time{} + if err := s.groupDuplicateRepo.CreateFromSource(ctx, duplicate, source.ID); err == nil { + hydrated, loadErr := s.groupRepo.GetByID(ctx, duplicate.ID) + if loadErr != nil { + return nil, fmt.Errorf("load duplicate group: %w", loadErr) + } + return hydrated, nil + } else if !errors.Is(err, ErrGroupExists) { + return nil, fmt.Errorf("create duplicate group: %w", err) + } + + // A unique conflict can be either the generated name or the operation ID. + // Recover first; if no operation row exists, advance to the next name. + recovered, recoverErr := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) + if recoverErr != nil { + return nil, recoverErr + } + if recovered != nil { + return recovered, nil + } + } +} diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go new file mode 100644 index 000000000..1d47cd699 --- /dev/null +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -0,0 +1,279 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/require" +) + +type duplicateGroupRepoStub struct { + GroupRepository + nextID int64 + groups map[int64]*Group + names map[string]struct{} + byOperation map[string]int64 + sourceBindings map[int64][]AccountGroup + createdBindings map[int64][]AccountGroup + createdFromSources []int64 + atomicCreateErr error +} + +func newDuplicateGroupRepoStub(source *Group) *duplicateGroupRepoStub { + repo := &duplicateGroupRepoStub{ + nextID: 100, + groups: make(map[int64]*Group), + names: make(map[string]struct{}), + byOperation: make(map[string]int64), + sourceBindings: make(map[int64][]AccountGroup), + createdBindings: make(map[int64][]AccountGroup), + } + if source != nil { + repo.groups[source.ID] = source + repo.names[source.Name] = struct{}{} + } + return repo +} + +func cloneGroupForDuplicateTest(group *Group) *Group { + if group == nil { + return nil + } + cloned := *group + cloned.DailyLimitUSD = cloneGroupValuePointer(group.DailyLimitUSD) + cloned.WeeklyLimitUSD = cloneGroupValuePointer(group.WeeklyLimitUSD) + cloned.MonthlyLimitUSD = cloneGroupValuePointer(group.MonthlyLimitUSD) + cloned.ImagePrice1K = cloneGroupValuePointer(group.ImagePrice1K) + cloned.ImagePrice2K = cloneGroupValuePointer(group.ImagePrice2K) + cloned.ImagePrice4K = cloneGroupValuePointer(group.ImagePrice4K) + cloned.VideoPrice480P = cloneGroupValuePointer(group.VideoPrice480P) + cloned.VideoPrice720P = cloneGroupValuePointer(group.VideoPrice720P) + cloned.VideoPrice1080P = cloneGroupValuePointer(group.VideoPrice1080P) + cloned.WebSearchPricePerCall = cloneGroupValuePointer(group.WebSearchPricePerCall) + cloned.FallbackGroupID = cloneGroupValuePointer(group.FallbackGroupID) + cloned.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest) + cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting) + cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...) + cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig) + cloned.ModelsListConfig.Models = append([]string(nil), group.ModelsListConfig.Models...) + cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...) + return &cloned +} + +func (r *duplicateGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) { + group := r.groups[id] + if group == nil { + return nil, ErrGroupNotFound + } + cloned := cloneGroupForDuplicateTest(group) + cloned.Hydrated = true + return cloned, nil +} + +func (r *duplicateGroupRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*Group, error) { + id := r.byOperation[operationID] + if id == 0 { + return nil, nil + } + return cloneGroupForDuplicateTest(r.groups[id]), nil +} + +func (r *duplicateGroupRepoStub) CreateFromSource(_ context.Context, group *Group, sourceGroupID int64) error { + if r.atomicCreateErr != nil { + return r.atomicCreateErr + } + if group.DuplicateOperationID != "" { + if _, exists := r.byOperation[group.DuplicateOperationID]; exists { + return ErrGroupExists + } + } + if _, exists := r.names[group.Name]; exists { + return ErrGroupExists + } + r.nextID++ + group.ID = r.nextID + group.CreatedAt = time.Now().UTC() + group.UpdatedAt = group.CreatedAt + bindings := append([]AccountGroup(nil), r.sourceBindings[sourceGroupID]...) + for i := range bindings { + bindings[i].GroupID = group.ID + } + group.AccountCount = int64(len(bindings)) + group.ActiveAccountCount = int64(len(bindings)) + r.createdBindings[group.ID] = bindings + r.createdFromSources = append(r.createdFromSources, sourceGroupID) + r.names[group.Name] = struct{}{} + r.groups[group.ID] = cloneGroupForDuplicateTest(group) + if group.DuplicateOperationID != "" { + r.byOperation[group.DuplicateOperationID] = group.ID + } + return nil +} + +func groupDuplicateTestPointer[T any](value T) *T { return &value } + +func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing.T) { + createdAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC) + source := &Group{ + ID: 41, + Name: "高级订阅", + Description: "configuration", + Platform: PlatformOpenAI, + RateMultiplier: 1.75, + PeakRateEnabled: true, + PeakStart: "09:00", + PeakEnd: "18:00", + PeakRateMultiplier: 1.2, + IsExclusive: true, + Status: StatusActive, + Hydrated: true, + SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: groupDuplicateTestPointer(11.0), + WeeklyLimitUSD: groupDuplicateTestPointer(22.0), + MonthlyLimitUSD: groupDuplicateTestPointer(33.0), + DefaultValidityDays: 91, + AllowImageGeneration: true, + AllowBatchImageGeneration: true, + ImageRateIndependent: true, + ImageRateMultiplier: 1.4, + ImagePrice1K: groupDuplicateTestPointer(0.01), + ImagePrice2K: groupDuplicateTestPointer(0.02), + ImagePrice4K: groupDuplicateTestPointer(0.04), + BatchImageDiscountMultiplier: 0.4, + BatchImageHoldMultiplier: 0.7, + VideoRateIndependent: true, + VideoRateMultiplier: 2.1, + VideoPrice480P: groupDuplicateTestPointer(0.1), + VideoPrice720P: groupDuplicateTestPointer(0.2), + VideoPrice1080P: groupDuplicateTestPointer(0.3), + WebSearchPricePerCall: groupDuplicateTestPointer(0.005), + ClaudeCodeOnly: true, + FallbackGroupID: groupDuplicateTestPointer(int64(7)), + FallbackGroupIDOnInvalidRequest: groupDuplicateTestPointer(int64(8)), + ModelRouting: map[string][]int64{"gpt-*": {13, 17}}, + ModelRoutingEnabled: true, + MCPXMLInject: true, + SupportedModelScopes: []string{"claude", "gemini_text"}, + SortOrder: 9, + AllowMessagesDispatch: true, + RequireOAuthOnly: true, + RequirePrivacySet: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4", + SonnetMappedModel: "gpt-5.3", + HaikuMappedModel: "gpt-5-mini", + ExactModelMappings: map[string]string{"claude-special": "gpt-special"}, + }, + ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, + RPMLimit: 99, + CreatedAt: createdAt, + UpdatedAt: createdAt, + AccountCount: 12, + ActiveAccountCount: 8, + RateLimitedAccountCount: 2, + DuplicateOperationID: "old-operation-must-not-copy", + AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}}, + } + repo := newDuplicateGroupRepoStub(source) + repo.sourceBindings[source.ID] = []AccountGroup{ + {AccountID: 13, GroupID: source.ID, Priority: 37}, + {AccountID: 17, GroupID: source.ID, Priority: 8}, + } + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:7", "stable-key") + + require.NoError(t, err) + require.NotEqual(t, source.ID, duplicate.ID) + require.Equal(t, "高级订阅 (Copy)", duplicate.Name) + require.Equal(t, duplicateGroupInactiveStatus, duplicate.Status) + require.True(t, duplicate.Hydrated, "the duplicate response is reloaded with derived counts") + require.Equal(t, source.Description, duplicate.Description) + require.Equal(t, source.Platform, duplicate.Platform) + require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier) + require.Equal(t, source.PeakRateMultiplier, duplicate.PeakRateMultiplier) + require.Equal(t, source.DefaultValidityDays, duplicate.DefaultValidityDays) + require.Equal(t, source.ImagePrice4K, duplicate.ImagePrice4K) + require.Equal(t, source.WebSearchPricePerCall, duplicate.WebSearchPricePerCall) + require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID) + require.Equal(t, source.ModelRouting, duplicate.ModelRouting) + require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) + require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) + require.Equal(t, source.RPMLimit, duplicate.RPMLimit) + require.EqualValues(t, 2, duplicate.AccountCount) + require.EqualValues(t, 2, duplicate.ActiveAccountCount) + require.NotEmpty(t, duplicate.DuplicateOperationID) + require.Equal(t, []int64{source.ID}, repo.createdFromSources) + require.Equal(t, []AccountGroup{ + {AccountID: 13, GroupID: duplicate.ID, Priority: 37}, + {AccountID: 17, GroupID: duplicate.ID, Priority: 8}, + }, repo.createdBindings[duplicate.ID]) + + duplicate.ModelRouting["gpt-*"][0] = 999 + duplicate.SupportedModelScopes[0] = "changed" + duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed" + duplicate.ModelsListConfig.Models[0] = "changed" + *duplicate.DailyLimitUSD = 999 + require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0]) + require.Equal(t, "claude", source.SupportedModelScopes[0]) + require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"]) + require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0]) + require.Equal(t, 11.0, *source.DailyLimitUSD) +} + +func TestDuplicateGroupRecoversSameOperationAndScopesByAdmin(t *testing.T) { + source := &Group{ID: 9, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + ctx := context.Background() + + first, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + retry, err := svc.DuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + recovered, err := svc.RecoverDuplicateGroup(ctx, source.ID, "admin:7", "same-key") + require.NoError(t, err) + otherAdmin, err := svc.DuplicateGroup(ctx, source.ID, "admin:8", "same-key") + require.NoError(t, err) + + require.Equal(t, first.ID, retry.ID) + require.Equal(t, first.ID, recovered.ID) + require.NotEqual(t, first.ID, otherAdmin.ID) + require.Equal(t, "team (Copy 2)", otherAdmin.Name) +} + +func TestDuplicateGroupAdvancesNameAndTruncatesUnicodeByRunes(t *testing.T) { + source := &Group{ID: 12, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + repo.names["team (Copy)"] = struct{}{} + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "") + require.NoError(t, err) + require.Equal(t, "team (Copy 2)", duplicate.Name) + + unicodeName := duplicateGroupName(strings.Repeat("组", 100), 23) + require.Equal(t, maxGroupNameRunes, utf8.RuneCountInString(unicodeName)) + require.True(t, strings.HasSuffix(unicodeName, " (Copy 23)")) +} + +func TestDuplicateGroupAtomicCreateFailureReturnsNoCopy(t *testing.T) { + source := &Group{ID: 15, Name: "team", Platform: PlatformAnthropic, Status: StatusActive} + repo := newDuplicateGroupRepoStub(source) + repo.atomicCreateErr = errors.New("binding insert failed") + svc := &adminServiceImpl{groupRepo: repo, groupDuplicateRepo: repo} + + duplicate, err := svc.DuplicateGroup(context.Background(), source.ID, "admin:1", "key") + + require.ErrorContains(t, err, "binding insert failed") + require.Nil(t, duplicate) + require.Len(t, repo.groups, 1) + require.Empty(t, repo.byOperation) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index a09bd14fb..c167b6be8 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -20,6 +20,7 @@ type AdminService interface { DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) + BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) @@ -39,6 +40,12 @@ type AdminService interface { GetGroup(ctx context.Context, id int64) (*Group, error) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) + // DuplicateGroup creates an inactive independent copy of a group's configuration + // and account bindings while preserving each binding's priority. + DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) + // RecoverDuplicateGroup returns a previously committed copy for an ambiguous retry. + // It never creates a group. + RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) 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) @@ -582,6 +589,7 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST type adminServiceImpl struct { userRepo UserRepository groupRepo GroupRepository + groupDuplicateRepo GroupDuplicateRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository proxyRepo ProxyRepository @@ -613,7 +621,7 @@ type userGroupRateBatchReader interface { // NewAdminService creates a new AdminService func NewAdminService( userRepo UserRepository, - groupRepo GroupRepository, + groupRepo AdminGroupRepository, accountRepo AdminAccountRepository, proxyRepo ProxyRepository, apiKeyRepo APIKeyRepository, @@ -635,6 +643,7 @@ func NewAdminService( return &adminServiceImpl{ userRepo: userRepo, groupRepo: groupRepo, + groupDuplicateRepo: groupRepo, accountRepo: accountRepo, accountDuplicateRepo: accountRepo, proxyRepo: proxyRepo, diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index ccc8d221b..fbd998cad 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -75,6 +75,9 @@ func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int6 func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_batch_limits_test.go b/backend/internal/service/admin_service_batch_limits_test.go new file mode 100644 index 000000000..ad1231149 --- /dev/null +++ b/backend/internal/service/admin_service_batch_limits_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +type batchLimitsUserRepoStub struct { + *userRepoStub + calls int + userIDs []int64 + concurrency *int + rpmLimit *int + affected int + err error +} + +func (s *batchLimitsUserRepoStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + s.calls++ + s.userIDs = append([]int64(nil), userIDs...) + s.concurrency = cloneBatchLimitValue(concurrency) + s.rpmLimit = cloneBatchLimitValue(rpmLimit) + return s.affected, s.err +} + +func cloneBatchLimitValue(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func TestAdminServiceBatchUpdateLimitsPassesOnlyProvidedFields(t *testing.T) { + concurrency := 0 + repo := &batchLimitsUserRepoStub{ + userRepoStub: &userRepoStub{}, + affected: 2, + } + invalidator := &authCacheInvalidatorStub{} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator} + + affected, err := service.BatchUpdateLimits( + context.Background(), + []int64{3, 0, 3, 7, -1}, + &concurrency, + nil, + ) + + require.NoError(t, err) + require.Equal(t, 2, affected) + require.Equal(t, []int64{3, 7}, repo.userIDs) + require.Equal(t, pointerToInt(0), repo.concurrency) + require.Nil(t, repo.rpmLimit) + require.Equal(t, []int64{3, 7}, invalidator.userIDs) +} + +func TestAdminServiceBatchUpdateLimitsDoesNotInvalidateCacheOnRepositoryError(t *testing.T) { + rpmLimit := 60 + repo := &batchLimitsUserRepoStub{ + userRepoStub: &userRepoStub{}, + err: errors.New("database unavailable"), + } + invalidator := &authCacheInvalidatorStub{} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator} + + affected, err := service.BatchUpdateLimits(context.Background(), []int64{1, 2}, nil, &rpmLimit) + + require.EqualError(t, err, "database unavailable") + require.Zero(t, affected) + require.Empty(t, invalidator.userIDs) +} + +func TestAdminServiceBatchUpdateLimitsRequiresAField(t *testing.T) { + repo := &batchLimitsUserRepoStub{userRepoStub: &userRepoStub{}} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: &authCacheInvalidatorStub{}} + + affected, err := service.BatchUpdateLimits(context.Background(), []int64{1}, nil, nil) + + require.Error(t, err) + require.Zero(t, affected) + require.Zero(t, repo.calls) +} + +func pointerToInt(value int) *int { + return &value +} diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 59974f9ad..be64576d4 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -133,6 +133,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) { if s.existsErr != nil { diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index c3737f5a1..1c6c47496 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -119,6 +119,9 @@ func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) ( func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailSyncRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index 87fe2346a..48ec83f55 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -459,6 +459,39 @@ func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs [ return affected, nil } +func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + if concurrency == nil && rpmLimit == nil { + return 0, fmt.Errorf("at least one of concurrency or rpm_limit is required") + } + + cleaned := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID <= 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + cleaned = append(cleaned, userID) + } + if len(cleaned) == 0 { + return 0, nil + } + + affected, err := s.userRepo.BatchUpdateLimits(ctx, cleaned, concurrency, rpmLimit) + if err != nil { + return 0, err + } + if s.authCacheInvalidator != nil { + for _, userID := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + } + return affected, nil +} + func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index 74ad5b2f0..eb6a35199 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -963,6 +963,9 @@ func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, in func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailBindUserRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/channel_monitor_duplicate_test.go b/backend/internal/service/channel_monitor_duplicate_test.go new file mode 100644 index 000000000..e61bb0245 --- /dev/null +++ b/backend/internal/service/channel_monitor_duplicate_test.go @@ -0,0 +1,239 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/require" +) + +type duplicateChannelMonitorRepoStub struct { + ChannelMonitorRepository + source *ChannelMonitor + created []*ChannelMonitor + byOperation map[string]*ChannelMonitor + nextID int64 +} + +func (r *duplicateChannelMonitorRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) { + if r.source == nil || r.source.ID != id { + return nil, ErrChannelMonitorNotFound + } + return r.source, nil +} + +func (r *duplicateChannelMonitorRepoStub) Create(_ context.Context, monitor *ChannelMonitor) error { + r.nextID++ + monitor.ID = 100 + r.nextID + monitor.CreatedAt = time.Date(2026, time.July, 16, 8, 0, 0, 0, time.UTC) + monitor.UpdatedAt = monitor.CreatedAt + + stored := *monitor + stored.ExtraModels = append([]string(nil), monitor.ExtraModels...) + stored.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders) + stored.BodyOverride = mustCloneJSONMap(monitor.BodyOverride) + if monitor.TemplateID != nil { + templateID := *monitor.TemplateID + stored.TemplateID = &templateID + } + r.created = append(r.created, &stored) + if stored.DuplicateOperationID != "" { + if r.byOperation == nil { + r.byOperation = make(map[string]*ChannelMonitor) + } + r.byOperation[stored.DuplicateOperationID] = &stored + } + return nil +} + +func (r *duplicateChannelMonitorRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*ChannelMonitor, error) { + monitor := r.byOperation[operationID] + if monitor == nil { + return nil, nil + } + cloned := *monitor + cloned.ExtraModels = append([]string(nil), monitor.ExtraModels...) + cloned.ExtraHeaders = cloneStringMap(monitor.ExtraHeaders) + cloned.BodyOverride = mustCloneJSONMap(monitor.BodyOverride) + return &cloned, nil +} + +func cloneStringMap(source map[string]string) map[string]string { + if source == nil { + return nil + } + cloned := make(map[string]string, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} + +func mustCloneJSONMap(source map[string]any) map[string]any { + if source == nil { + return nil + } + cloned, err := cloneChannelMonitorJSONMap(source) + if err != nil { + panic(err) + } + return cloned +} + +type duplicateChannelMonitorEncryptor struct { + decryptErr error + encryptErr error +} + +func (e *duplicateChannelMonitorEncryptor) Encrypt(plaintext string) (string, error) { + if e.encryptErr != nil { + return "", e.encryptErr + } + return "NEW:" + plaintext, nil +} + +func (e *duplicateChannelMonitorEncryptor) Decrypt(ciphertext string) (string, error) { + if e.decryptErr != nil { + return "", e.decryptErr + } + if !strings.HasPrefix(ciphertext, "OLD:") && !strings.HasPrefix(ciphertext, "NEW:") { + return "", errors.New("invalid ciphertext") + } + return strings.TrimPrefix(strings.TrimPrefix(ciphertext, "OLD:"), "NEW:"), nil +} + +func TestDuplicateChannelMonitorCopiesConfigurationAndResetsRuntimeState(t *testing.T) { + lastCheckedAt := time.Date(2026, time.July, 15, 7, 0, 0, 0, time.UTC) + templateID := int64(9) + source := &ChannelMonitor{ + ID: 42, + Name: "primary", + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeResponses, + Endpoint: "https://api.example.com", + APIKey: "OLD:top-secret", + PrimaryModel: "gpt-5.4-mini", + ExtraModels: []string{"gpt-5.4", "gpt-5.3"}, + GroupName: "production", + Enabled: true, + IntervalSeconds: 90, + JitterSeconds: 15, + LastCheckedAt: &lastCheckedAt, + CreatedBy: 4, + CreatedAt: lastCheckedAt.Add(-time.Hour), + UpdatedAt: lastCheckedAt, + TemplateID: &templateID, + ExtraHeaders: map[string]string{"User-Agent": "Codex"}, + BodyOverrideMode: MonitorBodyOverrideModeMerge, + BodyOverride: map[string]any{ + "metadata": map[string]any{"source": "original"}, + }, + } + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + + duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-primary") + + require.NoError(t, err) + require.Len(t, repo.created, 1) + stored := repo.created[0] + require.NotEqual(t, source.ID, duplicate.ID) + require.Equal(t, "primary (Copy)", duplicate.Name) + require.Equal(t, source.Provider, duplicate.Provider) + require.Equal(t, source.APIMode, duplicate.APIMode) + require.Equal(t, source.Endpoint, duplicate.Endpoint) + require.Equal(t, "top-secret", duplicate.APIKey) + require.Equal(t, "NEW:top-secret", stored.APIKey) + require.Equal(t, source.PrimaryModel, duplicate.PrimaryModel) + require.Equal(t, source.ExtraModels, duplicate.ExtraModels) + require.Equal(t, source.GroupName, duplicate.GroupName) + require.Equal(t, source.IntervalSeconds, duplicate.IntervalSeconds) + require.Equal(t, source.JitterSeconds, duplicate.JitterSeconds) + require.Equal(t, source.TemplateID, duplicate.TemplateID) + require.Equal(t, source.ExtraHeaders, duplicate.ExtraHeaders) + require.Equal(t, source.BodyOverrideMode, duplicate.BodyOverrideMode) + require.Equal(t, source.BodyOverride, duplicate.BodyOverride) + require.False(t, duplicate.Enabled) + require.Nil(t, duplicate.LastCheckedAt) + require.Equal(t, int64(77), duplicate.CreatedBy) + require.False(t, duplicate.APIKeyDecryptFailed) + require.NotEmpty(t, duplicate.DuplicateOperationID) + + duplicate.ExtraModels[0] = "changed" + duplicate.ExtraHeaders["User-Agent"] = "changed" + duplicate.BodyOverride["metadata"].(map[string]any)["source"] = "changed" + *duplicate.TemplateID = 10 + require.Equal(t, []string{"gpt-5.4", "gpt-5.3"}, source.ExtraModels) + require.Equal(t, "Codex", source.ExtraHeaders["User-Agent"]) + require.Equal(t, "original", source.BodyOverride["metadata"].(map[string]any)["source"]) + require.Equal(t, int64(9), *source.TemplateID) + require.Equal(t, "OLD:top-secret", source.APIKey) + require.True(t, source.Enabled) + require.Equal(t, &lastCheckedAt, source.LastCheckedAt) +} + +func TestDuplicateChannelMonitorNamePreservesSuffixWithinSchemaLimit(t *testing.T) { + name := duplicateChannelMonitorName(strings.Repeat("界", 100)) + + require.Equal(t, 100, utf8.RuneCountInString(name)) + require.True(t, strings.HasSuffix(name, " (Copy)")) +} + +func TestDuplicateChannelMonitorRejectsUndecryptableAPIKey(t *testing.T) { + source := &ChannelMonitor{ID: 42, Name: "broken", APIKey: "OLD:broken"} + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{decryptErr: errors.New("wrong encryption key")}) + + duplicate, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "copy-broken") + + require.Nil(t, duplicate) + require.ErrorIs(t, err, ErrChannelMonitorAPIKeyDecryptFailed) + require.Empty(t, repo.created) + require.Equal(t, "OLD:broken", source.APIKey) +} + +func TestDuplicateChannelMonitorRecoversCommittedCopyForSameOperation(t *testing.T) { + source := &ChannelMonitor{ + ID: 42, + Name: "primary", + Provider: MonitorProviderOpenAI, + APIMode: MonitorAPIModeResponses, + Endpoint: "https://api.example.com", + APIKey: "OLD:top-secret", + PrimaryModel: "gpt-5.4-mini", + IntervalSeconds: 60, + BodyOverrideMode: MonitorBodyOverrideModeOff, + } + repo := &duplicateChannelMonitorRepoStub{source: source} + service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{}) + + first, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key") + require.NoError(t, err) + retry, err := service.Duplicate(context.Background(), source.ID, 77, "admin:77", "stable-key") + require.NoError(t, err) + + require.Len(t, repo.created, 1, "same operation must not create a second monitor") + require.Equal(t, first.ID, retry.ID) + require.Equal(t, "top-secret", retry.APIKey) + require.Equal(t, first.DuplicateOperationID, retry.DuplicateOperationID) + require.NotContains(t, retry.ExtraHeaders, ChannelMonitorDuplicateOperationIDMetadataKey) + + otherActor, err := service.Duplicate(context.Background(), source.ID, 88, "admin:88", "stable-key") + require.NoError(t, err) + require.NotEqual(t, first.ID, otherActor.ID) + require.Len(t, repo.created, 2, "operation identity must include the actor scope") +} + +func TestChannelMonitorDuplicateOperationMetadataKeyCannotBeSubmittedAsHeader(t *testing.T) { + err := validateExtraHeaders(map[string]string{ + ChannelMonitorDuplicateOperationIDMetadataKey: "forged-operation", + }) + + require.Error(t, err) +} diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index b5dea2258..9cc793b43 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -2,8 +2,11 @@ package service import ( "context" + "crypto/sha256" + "encoding/json" "fmt" "log/slog" + "strconv" "strings" "sync" "time" @@ -21,6 +24,7 @@ type ChannelMonitorRepository interface { Update(ctx context.Context, m *ChannelMonitor) error Delete(ctx context.Context, id int64) error List(ctx context.Context, params ChannelMonitorListParams) ([]*ChannelMonitor, int64, error) + FindByDuplicateOperationID(ctx context.Context, operationID string) (*ChannelMonitor, error) // 调度器辅助 ListEnabled(ctx context.Context) ([]*ChannelMonitor, error) @@ -66,6 +70,14 @@ type ChannelMonitorService struct { scheduler MonitorScheduler } +const maxChannelMonitorNameRunes = 100 + +// ChannelMonitorDuplicateOperationIDMetadataKey is stored in the existing +// extra_headers JSON column to avoid a schema migration. The colon makes it an +// invalid HTTP header name, and repository adapters remove it before exposing +// ExtraHeaders to the service layer. +const ChannelMonitorDuplicateOperationIDMetadataKey = "sub2api:duplicate_operation_id" + // NewChannelMonitorService 创建渠道监控服务实例。 func NewChannelMonitorService(repo ChannelMonitorRepository, encryptor SecretEncryptor) *ChannelMonitorService { return &ChannelMonitorService{repo: repo, encryptor: encryptor} @@ -147,6 +159,164 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea return m, nil } +// Duplicate creates an independent, disabled copy of an existing monitor. +// The API key stays server-side: it is decrypted only long enough to encrypt a +// fresh ciphertext for the new row. Runtime state and history are not copied. +func (s *ChannelMonitorService) Duplicate( + ctx context.Context, + id, createdBy int64, + actorScope, operationKey string, +) (*ChannelMonitor, error) { + operationID := duplicateChannelMonitorOperationID(id, actorScope, operationKey) + existing, err := s.RecoverDuplicate(ctx, id, actorScope, operationKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + source, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + plainAPIKey, err := s.decryptAPIKeyForDuplicate(source) + if err != nil { + return nil, err + } + encryptedAPIKey, err := s.encryptor.Encrypt(plainAPIKey) + if err != nil { + return nil, fmt.Errorf("encrypt duplicate channel monitor api key: %w", err) + } + bodyOverride, err := cloneChannelMonitorJSONMap(source.BodyOverride) + if err != nil { + return nil, fmt.Errorf("clone duplicate channel monitor body override: %w", err) + } + + duplicate := &ChannelMonitor{ + Name: duplicateChannelMonitorName(source.Name), + Provider: source.Provider, + APIMode: source.APIMode, + Endpoint: source.Endpoint, + APIKey: encryptedAPIKey, + PrimaryModel: source.PrimaryModel, + ExtraModels: append([]string{}, source.ExtraModels...), + GroupName: source.GroupName, + Enabled: false, + IntervalSeconds: source.IntervalSeconds, + JitterSeconds: source.JitterSeconds, + CreatedBy: createdBy, + TemplateID: cloneInt64Pointer(source.TemplateID), + ExtraHeaders: cloneChannelMonitorHeaders(source.ExtraHeaders), + BodyOverrideMode: source.BodyOverrideMode, + BodyOverride: bodyOverride, + DuplicateOperationID: operationID, + } + if err := s.repo.Create(ctx, duplicate); err != nil { + return nil, fmt.Errorf("duplicate channel monitor: %w", err) + } + + // Match Create/Update response semantics: repository receives ciphertext, + // while handlers receive plaintext only so they can return the masked form. + duplicate.APIKey = plainAPIKey + return duplicate, nil +} + +// RecoverDuplicate performs a read-only lookup for a duplicate that was +// already committed for the same actor, source monitor, and idempotency key. +// It deliberately never repeats the create side effect. +func (s *ChannelMonitorService) RecoverDuplicate( + ctx context.Context, + id int64, + actorScope, operationKey string, +) (*ChannelMonitor, error) { + operationID := duplicateChannelMonitorOperationID(id, actorScope, operationKey) + if operationID == "" { + return nil, nil + } + monitor, err := s.repo.FindByDuplicateOperationID(ctx, operationID) + if err != nil { + return nil, fmt.Errorf("find duplicate channel monitor operation: %w", err) + } + if monitor == nil { + return nil, nil + } + s.decryptInPlace(monitor) + return monitor, nil +} + +func duplicateChannelMonitorOperationID(sourceID int64, actorScope, operationKey string) string { + operationKey = strings.TrimSpace(operationKey) + if operationKey == "" { + return "" + } + actorScope = strings.TrimSpace(actorScope) + if actorScope == "" { + actorScope = "admin:0" + } + payload := "admin.channel_monitors.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey + digest := sha256.Sum256([]byte(payload)) + return fmt.Sprintf("%x", digest) +} + +func (s *ChannelMonitorService) decryptAPIKeyForDuplicate(source *ChannelMonitor) (string, error) { + if source == nil || strings.TrimSpace(source.APIKey) == "" { + return "", ErrChannelMonitorAPIKeyDecryptFailed + } + plain, err := s.encryptor.Decrypt(source.APIKey) + if err != nil || strings.TrimSpace(plain) == "" { + slog.Warn("channel_monitor: decrypt api key for duplicate failed", + "monitor_id", source.ID, "error", err) + return "", ErrChannelMonitorAPIKeyDecryptFailed + } + return plain, nil +} + +func duplicateChannelMonitorName(sourceName string) string { + const suffix = " (Copy)" + nameRunes := []rune(strings.TrimSpace(sourceName)) + maxBaseRunes := maxChannelMonitorNameRunes - len([]rune(suffix)) + if len(nameRunes) > maxBaseRunes { + nameRunes = nameRunes[:maxBaseRunes] + } + return string(nameRunes) + suffix +} + +func cloneInt64Pointer(value *int64) *int64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneChannelMonitorHeaders(source map[string]string) map[string]string { + if source == nil { + return map[string]string{} + } + cloned := make(map[string]string, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} + +func cloneChannelMonitorJSONMap(source map[string]any) (map[string]any, error) { + if source == nil { + return nil, nil + } + payload, err := json.Marshal(source) + if err != nil { + return nil, err + } + cloned := make(map[string]any, len(source)) + if err := json.Unmarshal(payload, &cloned); err != nil { + return nil, err + } + return cloned, nil +} + // validateCreateParams 把 Create 入参的所有校验聚拢为一个函数,避免 Create 主体超过 30 行。 func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateProvider(p.Provider); err != nil { diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index 0f68bc29e..20ab1da93 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -51,6 +51,12 @@ type ChannelMonitor struct { BodyOverrideMode string // off / merge / replace BodyOverride map[string]any // 仅 mode != off 时使用 + // DuplicateOperationID is internal persistence metadata used to recover an + // already committed duplicate after an ambiguous idempotency-store failure. + // Repository implementations must keep it out of ExtraHeaders so it can + // never be serialized to clients or forwarded to an upstream provider. + DuplicateOperationID string + // APIKeyDecryptFailed 表示 APIKey 字段无法解密(密钥不一致或损坏)。 // 此时 APIKey 为空字符串,runner / RunCheck 必须跳过该监控并提示重填。 APIKeyDecryptFailed bool diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index d33101d5d..f4e3aeb00 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -249,6 +249,9 @@ func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context, func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { panic("unexpected BatchAddConcurrency call") } +func (r *contentModerationTestUserRepo) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + panic("unexpected BatchUpdateLimits call") +} func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 721212f6a..b687696cf 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -794,20 +794,34 @@ func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *t require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta") } -func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) { +func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { - name string - body string + name string + body string + wantModel string + wantOriginalSystem string + wantMetadataUserID string }{ { - name: "system array", - body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + name: "sonnet system array", + body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-3-5-sonnet-latest", + wantOriginalSystem: "x-anthropic-billing-header keep", }, { - name: "system string", - body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + name: "sonnet system string", + body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-3-5-sonnet-latest", + wantOriginalSystem: "x-anthropic-billing-header keep", + }, + { + name: "haiku full mimicry", + body: `{"model":"claude-haiku-4-5","metadata":{"user_id":"pi-session-metadata"},"system":[{"type":"text","text":"Pi project instructions","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`, + wantModel: "claude-haiku-4-5-20251001", + wantOriginalSystem: "Pi project instructions", + wantMetadataUserID: "pi-session-metadata", }, } @@ -816,6 +830,8 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("User-Agent", "pi/0.51.0") + c.Request.Header.Set("Anthropic-Beta", "client-only-beta") parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), PlatformAnthropic) require.NoError(t, err) @@ -825,9 +841,9 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"application/json"}, - "x-request-id": []string{"rid-oauth-preserve"}, + "x-request-id": []string{"rid-oauth-mimic"}, }, - Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), }, } @@ -846,7 +862,7 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( account := &Account{ ID: 301, - Name: "anthropic-oauth-preserve", + Name: "anthropic-oauth-mimic", Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 1, @@ -862,16 +878,27 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( require.NotNil(t, result) require.NotNil(t, upstream.lastReq) require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization")) - require.Contains(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"), claude.BetaOAuth) + finalBeta := getHeaderRaw(upstream.lastReq.Header, "anthropic-beta") + for _, beta := range claude.FullClaudeCodeMimicryBetas() { + require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta) + } + require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta")) + for key, value := range claude.DefaultHeaders { + require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key) + } + require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id")) + require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String()) system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) require.True(t, system.IsArray(), "system should be an array") arr := system.Array() require.Len(t, arr, 3, "system array should have billing block + cc prompt block + expansion block") - require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:") - require.Contains(t, arr[0].Get("text").String(), "cc_version=") + billingText := arr[0].Get("text").String() + require.Contains(t, billingText, "x-anthropic-billing-header:") + require.Contains(t, billingText, "cc_version="+claude.CLICurrentVersion+".") + require.Contains(t, billingText, "cc_entrypoint=cli;") require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String()) require.False(t, arr[1].Get("cache_control").Exists(), "身份前缀 block 不应带 cache_control") @@ -879,16 +906,80 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( require.Equal(t, claudeCodeSystemPromptExpansion, arr[2].Get("text").String()) require.Equal(t, "ephemeral", arr[2].Get("cache_control.type").String()) - // 原始 system prompt 应迁移至 messages 中 + // 原始 system prompt 应迁移至 messages 中。 messages := gjson.GetBytes(upstream.lastBody, "messages") require.True(t, messages.IsArray()) firstMsg := messages.Array()[0] require.Equal(t, "user", firstMsg.Get("role").String()) - require.Contains(t, firstMsg.Get("content.0.text").String(), "x-anthropic-billing-header keep") + require.Contains(t, firstMsg.Get("content.0.text").String(), tt.wantOriginalSystem) + + if tt.wantMetadataUserID != "" { + require.Equal(t, tt.wantMetadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists()) + } }) } } +func TestGatewayService_AnthropicOAuthRealClaudeCodeHaiku_PreservesClientHeadersAndBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + metadataUserID := FormatMetadataUserID( + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "550e8400-e29b-41d4-a716-446655440000", + "123e4567-e89b-42d3-a456-426614174000", + claude.CLICurrentVersion, + ) + body := []byte(`{"model":"claude-haiku-4-5-20251001","metadata":{"user_id":` + strconvQuote(metadataUserID) + `},"system":[{"type":"text","text":"Client-owned Claude Code system","cache_control":{"type":"ephemeral"}}],"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("User-Agent", "claude-cli/"+claude.CLICurrentVersion+" (external, cli)") + c.Request.Header.Set("X-Stainless-Package-Version", "real-client-package") + clientBeta := strings.Join([]string{ + claude.BetaClaudeCode, + claude.BetaOAuth, + claude.BetaInterleavedThinking, + claude.BetaContextManagement, + }, ",") + c.Request.Header.Set("Anthropic-Beta", clientBeta) + + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"msg_real_cc","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)), + }} + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{}, + deferredService: &DeferredService{}, + } + account := &Account{ + ID: 302, Name: "anthropic-real-cc", Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token"}, Status: StatusActive, Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, parsed) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, c.Request.Header.Get("User-Agent"), getHeaderRaw(upstream.lastReq.Header, "User-Agent")) + require.Equal(t, "real-client-package", getHeaderRaw(upstream.lastReq.Header, "X-Stainless-Package-Version")) + require.Equal(t, clientBeta, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta")) + require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"), "真实 CC 不应被强制写入 mimic request id") + require.Equal(t, gjson.GetBytes(body, "system").Raw, gjson.GetBytes(upstream.lastBody, "system").Raw) + require.Equal(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(upstream.lastBody, "messages").Raw) + require.Equal(t, metadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists()) + require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header:") +} + func TestGatewayService_AnthropicOAuth_SystemPromptInjectionCanBeDisabled(t *testing.T) { gin.SetMode(gin.TestMode) resetGatewayForwardingSettingsCacheForTest(t) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 80604dd8a..9fbbc674d 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -373,7 +373,7 @@ func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account // - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。 // - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。 // - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。 -// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。 +// - model:最终会发给上游的模型 ID(用于模型规范化 + metadata 版本选择)。 // // 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。 func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( @@ -390,7 +390,7 @@ func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody( systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) systemRewritten := false - if systemPromptInjectionEnabled && !strings.Contains(strings.ToLower(model), "haiku") { + if systemPromptInjectionEnabled { body = rewriteSystemForNonClaudeCodeWithPromptBlocks(body, normalizeSystemParam(systemRaw), systemPrompt, systemPromptBlocks) systemRewritten = true } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index df23974ee..b0f2e5b82 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -146,14 +146,15 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) } -func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_ExcludesContextManagement(t *testing.T) { +func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) { s := newTestGatewayServiceForBeta(false) final, ok := s.computeFinalAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) require.True(t, ok) - require.False(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), - "OAuth mimic haiku 仅注入 oauth + interleaved-thinking,不含 context-management") - require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) - require.True(t, anthropicBetaTokensContains(final, claude.BetaInterleavedThinking)) + require.Equal(t, strings.Join(claude.FullClaudeCodeMimicryBetas(), ","), final) + for _, beta := range claude.FullClaudeCodeMimicryBetas() { + require.Truef(t, anthropicBetaTokensContains(final, beta), + "OAuth mimic Haiku 必须包含完整 Claude Code beta 集合,缺少 %s", beta) + } } func TestComputeFinalAnthropicBeta_OAuthMimic_IgnoresClientBeta(t *testing.T) { @@ -206,17 +207,27 @@ func TestComputeFinalAnthropicBeta_APIKey_NoClientBetaInjectOff_ShouldNotSet(t * require.Equal(t, "", final) } +func TestComputeFinalAnthropicBeta_APIKeyHaiku_StillUsesAPIKeyBetas(t *testing.T) { + s := newTestGatewayServiceForBeta(true) + body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled"},"messages":[]}`) + final, ok := s.computeFinalAnthropicBeta("apikey", false, "claude-haiku-4-5", http.Header{}, body, nil) + require.True(t, ok) + require.Equal(t, claude.APIKeyHaikuBetaHeader, final) + require.False(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) + require.False(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) +} + // ============================================================================ // computeFinalCountTokensAnthropicBeta // ============================================================================ func TestComputeFinalCountTokensAnthropicBeta_OAuthMimic_AlwaysIncludesContextManagement(t *testing.T) { - // count_tokens 路径下 mimic 不按 haiku 排除:始终注入完整 mimicry beta + // count_tokens mimic 继续注入完整 mimicry beta,并额外携带 token-counting beta。 s := newTestGatewayServiceForBeta(false) final, ok := s.computeFinalCountTokensAnthropicBeta("oauth", true, "claude-haiku-4-5", http.Header{}, []byte(`{}`), nil) require.True(t, ok) require.True(t, anthropicBetaTokensContains(final, claude.BetaContextManagement), - "count_tokens + mimic 即使 haiku 也注入 context-management beta(与 messages 不同)") + "count_tokens + mimic Haiku 必须保留 context-management beta") require.True(t, anthropicBetaTokensContains(final, claude.BetaTokenCounting), "count_tokens 路径必须含 token-counting beta") } @@ -300,8 +311,8 @@ func TestNormalizeClaudeOAuthRequestBody_InjectsContextManagement_ThinkingAdapti } func TestNormalizeClaudeOAuthRequestBody_HaikuStillInjects_StripDeferredToSanitize(t *testing.T) { - // haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段; - // strip 由 buildUpstreamRequest 层的 sanitize 兜底(如果 final beta 不含 token)。 + // Haiku + thinking=enabled:normalize 阶段仍按 CLI mimicry 行为补齐字段; + // 最终是否保留仍由 beta 能力对称的 sanitize 统一决定。 body := []byte(`{"model":"claude-haiku-4-5","thinking":{"type":"enabled","budget_tokens":1000},"messages":[]}`) out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{}) require.True(t, gjson.GetBytes(out, "context_management").Exists(), @@ -322,6 +333,30 @@ func TestNormalizeClaudeOAuthRequestBody_NoThinking_NoInject(t *testing.T) { require.False(t, gjson.GetBytes(out, "context_management").Exists()) } +func TestNormalizeClaudeOAuthRequestBody_HaikuShortModelStillNormalizesToDatedID(t *testing.T) { + body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`) + out, modelID := normalizeClaudeOAuthRequestBody(body, "claude-haiku-4-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "claude-haiku-4-5-20251001", modelID) + require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) +} + +func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) { + account := &Account{ID: 405, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + body := []byte(`{"model":"claude-haiku-4-5","system":"Pi project instructions","messages":[{"role":"user","content":"hello"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + + out := svc.applyClaudeCodeOAuthMimicryToBody( + context.Background(), nil, account, body, "Pi project instructions", "claude-haiku-4-5", + ) + + system := gjson.GetBytes(out, "system").Array() + require.Len(t, system, 3) + require.Contains(t, system[0].Get("text").String(), "x-anthropic-billing-header:") + require.Equal(t, claudeCodeSystemPrompt, system[1].Get("text").String()) + require.Contains(t, gjson.GetBytes(out, "messages.0.content.0.text").String(), "Pi project instructions") + require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) +} + // ============================================================================ // passthrough 集成测试:buildUpstreamRequest- // AnthropicAPIKeyPassthrough 与 buildCountTokensRequestAnthropicAPIKeyPassthrough @@ -411,7 +446,7 @@ func TestBuildCountTokensRequestAnthropicAPIKeyPassthrough_StripsContextManageme // 这个测试能挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。 // ============================================================================ -func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t *testing.T) { +func TestBuildUpstreamRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -422,8 +457,7 @@ func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t Status: StatusActive, Schedulable: true, } - // haiku + mimic CC → final beta = HaikuBetaHeader(不含 context-management)→ - // body 必须 strip。 + // Haiku + mimic CC 使用完整 beta,其中包含 context-management;body 必须对称保留。 body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"messages":[]}`) svc := &GatewayService{cfg: &config.Config{}} req, _, err := svc.buildUpstreamRequest( @@ -435,10 +469,38 @@ func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsContextManagementEndToEnd(t outBody := readUpstreamBodyForTest(t, req) outBeta := getHeaderRaw(req.Header, "anthropic-beta") - require.False(t, gjson.GetBytes(outBody, "context_management").Exists(), - "OAuth mimic + haiku 端到端:outgoing body 不应含 context_management") - require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement), - "对称约束:outgoing anthropic-beta header 也不带 context-management beta") + require.True(t, gjson.GetBytes(outBody, "context_management").Exists(), + "OAuth mimic + Haiku 端到端:outgoing body 必须保留 context_management") + require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement), + "对称约束:outgoing anthropic-beta header 必须包含 context-management beta") + require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaClaudeCode), + "Haiku mimic 必须携带 claude-code beta") +} + +func TestBuildUpstreamRequest_APIKeyHaiku_RemainsUnmimicked(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + account := &Account{ + ID: 404, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-ant-xxx"}, + Status: StatusActive, Schedulable: true, + } + body := []byte(`{"model":"claude-haiku-4-5","system":"API-key client system","thinking":{"type":"enabled"},"messages":[]}`) + svc := newTestGatewayServiceForBeta(true) + req, _, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, + "sk-ant-xxx", "apikey", "claude-haiku-4-5", false, false, + ) + require.NoError(t, err) + + outBody := readUpstreamBodyForTest(t, req) + require.Equal(t, "API-key client system", gjson.GetBytes(outBody, "system").String()) + require.Equal(t, claude.APIKeyHaikuBetaHeader, getHeaderRaw(req.Header, "anthropic-beta")) + require.False(t, anthropicBetaTokensContains(getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth)) + require.NotContains(t, string(outBody), "x-anthropic-billing-header:") } func TestBuildUpstreamRequest_OAuthMimicNonHaiku_PreservesContextManagementEndToEnd(t *testing.T) { @@ -504,8 +566,8 @@ func TestBuildUpstreamRequest_OAuthTransparentHaikuWithRealCCBeta_PreservesField // count_tokens 主路径 E2E 集成测试 func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndToEnd(t *testing.T) { - // count_tokens 路径下 mimic 不按 haiku 排除,始终注入 BetaContextManagement - // → sanitize 看到最终 beta header 含 context-management beta → 字段保留。 + // count_tokens 继续注入 BetaContextManagement 和 BetaTokenCounting; + // sanitize 看到最终 beta header 含 context-management beta 后保留字段。 gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 141898978..2e6093d18 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -179,19 +179,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。 // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。 systemRewritten := false - if !strings.Contains(strings.ToLower(reqModel), "haiku") { - systemRaw, _ := parsed.SystemValue() - systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) - if systemPromptInjectionEnabled { - if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { - return nil, err - } - systemRewritten = true + systemRaw, _ := parsed.SystemValue() + systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx) + if systemPromptInjectionEnabled { + if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil { + return nil, err } + systemRewritten = true } // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); - // 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。 + // 未重写时(注入开关关闭)剥离客户端 cache_control,与原有行为一致。 // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} if s.identityService != nil && c != nil { diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ac13f0be5..c5d962f20 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -373,8 +373,8 @@ func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) return claude.BetaOAuth + "," + clientBetaHeader } - // 客户端没传,根据模型生成 - // haiku 模型不需要 claude-code beta + // OAuth 真实客户端透传且客户端没传 beta 时,根据模型生成默认值。 + // Haiku 的透传默认值不补 claude-code beta;mimic 路径不会调用本分支。 if strings.Contains(strings.ToLower(modelID), "haiku") { return claude.HaikuBetaHeader } @@ -496,13 +496,9 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // mimic 路径:原代码跳过白名单透传,incomingBeta 总是空字符串。 - // 这里传空 string 以严格对齐原行为。 - requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - if !strings.Contains(strings.ToLower(modelID), "haiku") { - requiredBetas = claude.FullClaudeCodeMimicryBetas() - } - return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true + // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须 + // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。 + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true @@ -526,8 +522,8 @@ func (s *GatewayService) computeFinalAnthropicBeta( // 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的 // 两条特殊规则: // -// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting -// (与 messages 不同的是:不按 haiku 排除;count_tokens 始终携带 token-counting beta) +// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting; +// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。 // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) // diff --git a/backend/internal/service/grok_upstream_url_test.go b/backend/internal/service/grok_upstream_url_test.go index 5cbab7aa4..61c824e2a 100644 --- a/backend/internal/service/grok_upstream_url_test.go +++ b/backend/internal/service/grok_upstream_url_test.go @@ -120,19 +120,36 @@ func TestGrokOAuthURLPolicy(t *testing.T) { require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target) }) - t.Run("stored official-host variant stays on CLI gateway", func(t *testing.T) { + t.Run("stored official API endpoint is honored (manual endpoint switch)", func(t *testing.T) { account := &Account{ Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ - "base_url": "HTTPS://API.X.AI:443/", + "base_url": xai.DefaultBaseURL, }, } cfg := &config.Config{} target, err := buildGrokResponsesURL(account, cfg) require.NoError(t, err) - require.Equal(t, xai.DefaultCLIBaseURL+"/responses", target) + require.Equal(t, xai.DefaultBaseURL+"/responses", target) + }) + + t.Run("stored regional API endpoint is trusted even under restrictive allowlist", func(t *testing.T) { + account := &Account{ + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "base_url": "https://us-west-2.api.x.ai/v1", + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = true + cfg.Security.URLAllowlist.UpstreamHosts = []string{"other.example.test"} + + target, err := buildGrokResponsesURL(account, cfg) + require.NoError(t, err) + require.Equal(t, "https://us-west-2.api.x.ai/v1/responses", target) }) t.Run("custom forwarding address follows operator policy", func(t *testing.T) { diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 346eef7b9..58265b668 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -28,6 +28,9 @@ type Group struct { IsExclusive bool Status string Hydrated bool // indicates the group was loaded from a trusted repository source + // DuplicateOperationID is internal persistence metadata used only to recover + // an already committed one-click copy. It must never be mapped to API DTOs. + DuplicateOperationID string SubscriptionType string DailyLimitUSD *float64 diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 93078aa63..a78b5975b 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -37,6 +37,22 @@ type GroupRepository interface { UpdateSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error } +type GroupDuplicateRepository interface { + // FindByDuplicateOperationID performs the read-only recovery lookup used + // after an ambiguous idempotency-store failure. + FindByDuplicateOperationID(ctx context.Context, operationID string) (*Group, error) + // CreateFromSource atomically persists the group, copies the source group's + // exact account priorities, and writes the scheduler outbox event. + CreateFromSource(ctx context.Context, group *Group, sourceGroupID int64) error +} + +// AdminGroupRepository makes the group-duplication write capability an explicit +// admin-service dependency without widening gateway-only group test doubles. +type AdminGroupRepository interface { + GroupRepository + GroupDuplicateRepository +} + // GroupSortOrderUpdate 分组排序更新 type GroupSortOrderUpdate struct { ID int64 `json:"id"` diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 1ad532e9e..32a9bd57d 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "io" @@ -219,10 +221,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest // FetchCodexModelsManifest fetches the live Codex models manifest from either // the ChatGPT backend for OAuth accounts or a custom upstream for API key accounts. // -// The response body is passed through verbatim: the manifest schema evolves -// with Codex client releases, and interpreting it here would force the gateway -// to chase upstream changes. Passing it through keeps the gateway -// schema-agnostic and always reflects the account's real entitlements. +// After validating the stable top-level envelope, the response body is passed +// through verbatim. Model entries evolve with Codex client releases, so the +// gateway deliberately avoids interpreting their fields and reflects the +// account's real entitlements without chasing upstream schema changes. func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { if account == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") @@ -461,9 +463,43 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: isRetryableCodexModelsManifestTransportError(err), } } + if err := validateCodexModelsManifestEnvelope(body); err != nil { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf( + http.StatusBadGateway, + "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST", + "codex models manifest upstream returned an invalid envelope: %v", + err, + ), + retryable: true, + } + } return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil } +func validateCodexModelsManifestEnvelope(body []byte) error { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("decode JSON object: %w", err) + } + if envelope == nil { + return errors.New("expected a JSON object") + } + models, ok := envelope["models"] + if !ok { + return errors.New("missing top-level models array") + } + models = bytes.TrimSpace(models) + var entries []json.RawMessage + if len(models) == 0 || models[0] != '[' { + return errors.New("top-level models field is not an array") + } + if err := json.Unmarshal(models, &entries); err != nil { + return fmt.Errorf("decode top-level models array: %w", err) + } + return nil +} + func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string { hasher := sha256.New() _, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url) diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 8554c0725..307599368 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -470,6 +470,81 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { } } +func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "OpenAI models list", body: `{"object":"list","data":[]}`}, + {name: "invalid JSON", body: `{"models":`}, + {name: "non-object", body: `[]`}, + {name: "null object", body: `null`}, + {name: "missing models", body: `{}`}, + {name: "models object", body: `{"models":{}}`}, + {name: "models null", body: `{"models":null}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.body)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + _, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example"), + "0.144.0", + "", + ) + if err == nil { + t.Fatal("expected invalid manifest error, got nil") + } + if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" { + t.Errorf("error reason: got %q", infraerrors.Reason(err)) + } + if !IsRetryableCodexModelsManifestError(err) { + t.Error("invalid upstream manifest must be retryable") + } + }) + } +} + +func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) { + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body := `{"object":"list","data":[]}` + if calls.Add(1) > 1 { + body = `{"models":[{"slug":"gpt-5.6"}]}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + account := newCodexModelsAPIKeyTestAccount("https://upstream.example") + if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil { + t.Fatal("expected invalid manifest error on first fetch") + } + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + if err != nil { + t.Fatalf("second fetch returned error: %v", err) + } + if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want { + t.Errorf("body: got %q, want %q", got, want) + } + if got := calls.Load(); got != 2 { + t.Errorf("upstream calls: got %d, want 2", got) + } +} + func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) { const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}` var calls atomic.Int32 @@ -693,7 +768,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. calls.Add(1) body := `{"models":[]}` if strings.Contains(req.URL.Host, "large") { - body = strings.Repeat("x", (1<<20)+1) + body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}` } return &http.Response{ StatusCode: http.StatusOK, diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index bf6ee5033..71e04d867 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -288,20 +288,20 @@ func TestBuildGrokResponsesRequestAllowsPublicAPIKeyBaseURLByDefault(t *testing. require.NotEqual(t, grokUpstreamUserAgent, req.Header.Get("User-Agent")) } -func TestBuildGrokResponsesRequestPinsOAuthOfficialVariantBaseURL(t *testing.T) { +func TestBuildGrokResponsesRequestHonorsOAuthOfficialEndpointSwitch(t *testing.T) { t.Parallel() account := &Account{ Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{ - "base_url": "HTTPS://API.X.AI:443/", + "base_url": xai.DefaultBaseURL, }, } req, err := buildGrokResponsesRequest(context.Background(), nil, account, []byte(`{"model":"grok-4.3"}`), "access-token", "", nil) require.NoError(t, err) - require.Equal(t, xai.DefaultCLIBaseURL+"/responses", req.URL.String()) + require.Equal(t, xai.DefaultBaseURL+"/responses", req.URL.String()) } func TestBuildGrokResponsesRequestAppliesHeaderOverridesLast(t *testing.T) { diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index de2446eb7..300bea376 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -649,6 +649,12 @@ func TestNormalizeCompletedImageGenerationStatus(t *testing.T) { want: `{"type":"response.output_item.added","item":{"type":"image_generation_call","status":"generating","result":"image-data"}}`, wantChanged: false, }, + { + name: "done preserves base64 result", + input: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`, + want: `{"type":"response.done","response":{"output":[{"type":"image_generation_call","status":"completed","result":"iVBORw0KGgoAAAANSUhEUg/+=="}]}}`, + wantChanged: true, + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index f6a7dca25..67bffe6ce 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -803,6 +803,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( wroteDownstream, ) } + if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { + upstreamMessage = normalized + } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) if responseID == "" && eventResponseID != "" { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 8fbf168f6..c90a1ebc0 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -38,6 +38,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT captureConn := &openAIWSCaptureConn{ events: [][]byte{ + []byte(`{"type":"response.output_item.done","item":{"id":"ig_ingress_1","type":"image_generation_call","status":"generating","result":"iVBORw0KGgoAAAANSUhEUg/+=="}}`), []byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), []byte(`{"type":"response.completed","response":{"id":"resp_ingress_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), }, @@ -138,6 +139,10 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT } writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false}`) + firstTurnImageEvent := readMessage() + require.Equal(t, "response.output_item.done", gjson.GetBytes(firstTurnImageEvent, "type").String()) + require.Equal(t, "completed", gjson.GetBytes(firstTurnImageEvent, "item.status").String()) + require.Equal(t, "iVBORw0KGgoAAAANSUhEUg/+==", gjson.GetBytes(firstTurnImageEvent, "item.result").String()) firstTurnEvent := readMessage() require.Equal(t, "response.completed", gjson.GetBytes(firstTurnEvent, "type").String()) require.Equal(t, "resp_ingress_turn_1", gjson.GetBytes(firstTurnEvent, "response.id").String()) @@ -765,7 +770,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR upstreamConn := &openAIWSCaptureConn{ events: [][]byte{ - []byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":3}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_passthrough_turn_1","model":"gpt-5.1","output":[{"id":"ig_passthrough_1","type":"image_generation_call","status":"generating","result":"final-image"}],"usage":{"input_tokens":2,"output_tokens":3}}}`), }, } captureDialer := &openAIWSCaptureDialer{conn: upstreamConn} @@ -858,6 +863,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR require.NoError(t, readErr) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) require.Equal(t, "resp_passthrough_turn_1", gjson.GetBytes(event, "response.id").String()) + require.Equal(t, "completed", gjson.GetBytes(event, "response.output.0.status").String()) _ = clientConn.Close(coderws.StatusNormalClosure, "done") select { @@ -1047,7 +1053,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_bridge_1"}}, Body: io.NopCloser(strings.NewReader( "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" + - "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_http_bridge_1\",\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + + "data: {\"type\":\"response.done\",\"response\":{\"id\":\"resp_http_bridge_1\",\"output\":[{\"id\":\"ig_bridge_1\",\"type\":\"image_generation_call\",\"status\":\"in_progress\",\"result\":\"final-image\"}],\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n" + "data: [DONE]\n\n", )), }, @@ -1145,8 +1151,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe _, event2, readErr2 := clientConn.Read(readCtx2) cancelRead2() require.NoError(t, readErr2) - require.Equal(t, "response.completed", gjson.GetBytes(event2, "type").String()) + require.Equal(t, "response.done", gjson.GetBytes(event2, "type").String()) require.Equal(t, "resp_http_bridge_1", gjson.GetBytes(event2, "response.id").String()) + require.Equal(t, "completed", gjson.GetBytes(event2, "response.output.0.status").String()) _ = clientConn.Close(coderws.StatusNormalClosure, "done") diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 37c68a412..0d031de18 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -281,6 +281,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi "item": map[string]any{ "id": "ig_ws_1", "type": "image_generation_call", + "status": "generating", "result": "final-image", }, }); err != nil { @@ -296,6 +297,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi map[string]any{ "id": "ig_ws_1", "type": "image_generation_call", + "status": "in_progress", "result": "final-image", }, }, @@ -375,6 +377,7 @@ func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testi require.Equal(t, 4, result.Usage.OutputTokens) require.True(t, result.OpenAIWSMode) require.Equal(t, "resp_ws_image_1", gjson.GetBytes(rec.Body.Bytes(), "id").String()) + require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "output.0.status").String()) } func requestToJSONString(payload map[string]any) string { diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index c406b6b33..62aee29c2 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -504,6 +504,9 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") return nil, fmt.Errorf("openai ws read event: %w", readErr) } + if normalized, changed := normalizeCompletedImageGenerationStatus(message); changed { + message = normalized + } eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message) if eventType == "" { diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 9140f6144..09b3dc062 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -331,6 +331,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } upstreamMessage := []byte(trimmedData) + if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed { + upstreamMessage = normalized + } eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage) if responseID == "" && eventResponseID != "" { responseID = eventResponseID diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 4505ffb66..af1bf7456 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -212,6 +212,11 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw if ctx == nil { ctx = context.Background() } + if msgType == coderws.MessageText { + if normalized, changed := normalizeCompletedImageGenerationStatus(payload); changed { + payload = normalized + } + } return c.conn.Write(ctx, msgType, payload) } diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go index 0f4fa185f..aac42dac7 100644 --- a/backend/internal/service/totp_service.go +++ b/backend/internal/service/totp_service.go @@ -141,6 +141,31 @@ func (s *TotpService) GetStatus(ctx context.Context, userID int64) (*TotpStatus, }, nil } +// usesEmailVerification 判断 TOTP 启用/停用时的身份校验方式。 +// 管理员一律使用密码校验:管理员账号的邮箱常为占位地址收不到验证码, +// 且管理员凭证失守时攻击者往往同时控制通知邮箱,邮箱验证码不构成有效防线。 +// 普通用户维持原有行为:开启邮箱验证时用邮箱验证码,否则用密码。 +func (s *TotpService) usesEmailVerification(ctx context.Context, user *User) bool { + return user.Role != RoleAdmin && s.settingService.IsEmailVerifyEnabled(ctx) +} + +// verifyIdentity 按 usesEmailVerification 的结果校验邮箱验证码或密码。 +func (s *TotpService) verifyIdentity(ctx context.Context, user *User, emailCode, password string) error { + if s.usesEmailVerification(ctx, user) { + if emailCode == "" { + return ErrVerifyCodeRequired + } + return s.emailService.VerifyCode(ctx, user.Email, emailCode) + } + if password == "" { + return ErrPasswordRequired + } + if !user.CheckPassword(password) { + return ErrPasswordIncorrect + } + return nil +} + // InitiateSetup starts the TOTP setup process // If email verification is enabled, emailCode is required; otherwise password is required func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode, password string) (*TotpSetupResponse, error) { @@ -159,23 +184,8 @@ func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode return nil, ErrTotpAlreadyEnabled } - // Verify identity based on email verification setting - if s.settingService.IsEmailVerifyEnabled(ctx) { - // Email verification enabled - verify email code - if emailCode == "" { - return nil, ErrVerifyCodeRequired - } - if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { - return nil, err - } - } else { - // Email verification disabled - verify password - if password == "" { - return nil, ErrPasswordRequired - } - if !user.CheckPassword(password) { - return nil, ErrPasswordIncorrect - } + if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil { + return nil, err } // Generate a new TOTP key @@ -306,23 +316,8 @@ func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, pass return ErrTotpNotSetup } - // Verify identity based on email verification setting - if s.settingService.IsEmailVerifyEnabled(ctx) { - // Email verification enabled - verify email code - if emailCode == "" { - return ErrVerifyCodeRequired - } - if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { - return err - } - } else { - // Email verification disabled - verify password - if password == "" { - return ErrPasswordRequired - } - if !user.CheckPassword(password) { - return ErrPasswordIncorrect - } + if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil { + return err } // Disable TOTP @@ -532,12 +527,17 @@ type VerificationMethod struct { Method string `json:"method"` // "email" or "password" } -// GetVerificationMethod returns the verification method for TOTP operations -func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMethod { - if s.settingService.IsEmailVerifyEnabled(ctx) { - return &VerificationMethod{Method: "email"} +// GetVerificationMethod returns the verification method for TOTP operations. +// 与 verifyIdentity 保持同一判定:管理员一律返回 password。 +func (s *TotpService) GetVerificationMethod(ctx context.Context, userID int64) (*VerificationMethod, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) } - return &VerificationMethod{Method: "password"} + if s.usesEmailVerification(ctx, user) { + return &VerificationMethod{Method: "email"}, nil + } + return &VerificationMethod{Method: "password"}, nil } // SendVerifyCode sends an email verification code for TOTP operations diff --git a/backend/internal/service/totp_verification_method_test.go b/backend/internal/service/totp_verification_method_test.go new file mode 100644 index 000000000..baae2d674 --- /dev/null +++ b/backend/internal/service/totp_verification_method_test.go @@ -0,0 +1,107 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// totpVMUserRepoStub 仅实现 TOTP 验证方式测试所需方法;未桩方法调用即 panic(嵌入 nil 接口)。 +type totpVMUserRepoStub struct { + UserRepository + user *User + totpDisabled bool + disableCalled bool +} + +func (s *totpVMUserRepoStub) GetByID(ctx context.Context, id int64) (*User, error) { + if s.user == nil { + return nil, errors.New("user not found") + } + return s.user, nil +} + +func (s *totpVMUserRepoStub) DisableTotp(ctx context.Context, userID int64) error { + s.disableCalled = true + s.totpDisabled = true + return nil +} + +type totpVMSettingRepoStub struct { + SettingRepository + values map[string]string +} + +func (s *totpVMSettingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + v, ok := s.values[key] + if !ok { + return "", errors.New("setting not found") + } + return v, nil +} + +func newTotpVMService(t *testing.T, user *User, emailVerifyEnabled bool) (*TotpService, *totpVMUserRepoStub) { + t.Helper() + userRepo := &totpVMUserRepoStub{user: user} + values := map[string]string{} + if emailVerifyEnabled { + values[SettingKeyEmailVerifyEnabled] = "true" + } + settingSvc := NewSettingService(&totpVMSettingRepoStub{values: values}, nil) + return NewTotpService(userRepo, nil, nil, settingSvc, nil, nil), userRepo +} + +func TestGetVerificationMethodAdminAlwaysPassword(t *testing.T) { + admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin} + svc, _ := newTotpVMService(t, admin, true) + + method, err := svc.GetVerificationMethod(context.Background(), admin.ID) + require.NoError(t, err) + require.Equal(t, "password", method.Method) +} + +func TestGetVerificationMethodRegularUserFollowsEmailVerifySetting(t *testing.T) { + user := &User{ID: 2, Email: "user@example.com", Role: RoleUser} + + svcEmailOn, _ := newTotpVMService(t, user, true) + method, err := svcEmailOn.GetVerificationMethod(context.Background(), user.ID) + require.NoError(t, err) + require.Equal(t, "email", method.Method) + + svcEmailOff, _ := newTotpVMService(t, user, false) + method, err = svcEmailOff.GetVerificationMethod(context.Background(), user.ID) + require.NoError(t, err) + require.Equal(t, "password", method.Method) +} + +func TestTotpDisableAdminUsesPasswordEvenWithEmailVerifyEnabled(t *testing.T) { + admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin, TotpEnabled: true} + require.NoError(t, admin.SetPassword("correct-password")) + svc, userRepo := newTotpVMService(t, admin, true) + + // 缺密码 → 要求密码(而非邮箱验证码)。 + err := svc.Disable(context.Background(), admin.ID, "", "") + require.ErrorIs(t, err, ErrPasswordRequired) + + // 密码错误 → 拒绝。 + err = svc.Disable(context.Background(), admin.ID, "", "wrong-password") + require.ErrorIs(t, err, ErrPasswordIncorrect) + + // 密码正确 → 成功停用;全程不需要邮箱验证码(emailService 为 nil,走到邮箱分支会 panic)。 + err = svc.Disable(context.Background(), admin.ID, "", "correct-password") + require.NoError(t, err) + require.True(t, userRepo.disableCalled) +} + +func TestTotpDisableRegularUserStillRequiresEmailCode(t *testing.T) { + user := &User{ID: 2, Email: "user@example.com", Role: RoleUser, TotpEnabled: true} + require.NoError(t, user.SetPassword("whatever")) + svc, _ := newTotpVMService(t, user, true) + + err := svc.Disable(context.Background(), user.ID, "", "whatever") + require.ErrorIs(t, err, ErrVerifyCodeRequired) +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 98b0c8f32..ae630559f 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -107,6 +107,7 @@ type UserRepository interface { UpdateConcurrency(ctx context.Context, id int64, amount int) error BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) + BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) ExistsByEmail(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index ebe21cc89..e667377d5 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -208,7 +208,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } -func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } +func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} +func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) { out := make([]UserAuthIdentityRecord, len(m.identities)) copy(out, m.identities) diff --git a/backend/migrations/181_group_duplicate_operation_id.sql b/backend/migrations/181_group_duplicate_operation_id.sql new file mode 100644 index 000000000..b20ef2fb3 --- /dev/null +++ b/backend/migrations/181_group_duplicate_operation_id.sql @@ -0,0 +1,8 @@ +-- Persist an internal operation identity for safely recovering an already +-- committed group duplicate when the idempotency response write is ambiguous. +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS duplicate_operation_id VARCHAR(64); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_duplicate_operation_id_active + ON groups (duplicate_operation_id) + WHERE duplicate_operation_id IS NOT NULL AND deleted_at IS NULL; diff --git a/frontend/src/api/__tests__/admin.channelMonitor.duplicate.spec.ts b/frontend/src/api/__tests__/admin.channelMonitor.duplicate.spec.ts new file mode 100644 index 000000000..ac5d2b05a --- /dev/null +++ b/frontend/src/api/__tests__/admin.channelMonitor.duplicate.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { post } = vi.hoisted(() => ({ + post: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + apiClient: { post }, +})) + +import { duplicate } from '@/api/admin/channelMonitor' + +describe('admin channel monitor duplicate API', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + post.mockReset() + post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } }) + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends a stable idempotency key with the duplicate request', async () => { + const monitor = await duplicate(42) + + expect(post).toHaveBeenCalledWith('/admin/channel-monitors/42/duplicate', undefined, { + headers: { + 'Idempotency-Key': 'channel-monitor-duplicate-7-42-11111111-1111-4111-8111-111111111111', + }, + }) + expect(monitor).toEqual({ id: 43, name: 'primary (Copy)' }) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after an ambiguous failed request', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(99)).rejects.toThrow('network timeout') + + post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } }) + await duplicate(99) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after a page reload', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(77)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.resetModules() + post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } }) + const { duplicate: duplicateAfterReload } = await import('@/api/admin/channelMonitor') + await duplicateAfterReload(77) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) + + it('does not reuse an operation key across administrators for the same monitor', async () => { + post.mockRejectedValueOnce(new Error('first admin timeout')) + await expect(duplicate(55)).rejects.toThrow('first admin timeout') + const firstAdminHeaders = post.mock.calls[0][2].headers + + localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '22222222-2222-4222-8222-222222222222' + ) + post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } }) + await duplicate(55) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders) + expect(post.mock.calls[1][2].headers).toEqual({ + 'Idempotency-Key': 'channel-monitor-duplicate-8-55-22222222-2222-4222-8222-222222222222', + }) + expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:7:55')).toBe( + firstAdminHeaders['Idempotency-Key'] + ) + expect(sessionStorage.getItem('sub2api:admin:channel-monitor-duplicate:8:55')).toBeNull() + }) + + it('does not persist or reuse keys when the current user cannot be parsed', async () => { + localStorage.setItem('auth_user', '{invalid json') + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(66)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '33333333-3333-4333-8333-333333333333' + ) + post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } }) + await duplicate(66) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) +}) diff --git a/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts b/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts new file mode 100644 index 000000000..cfe6ab65f --- /dev/null +++ b/frontend/src/api/__tests__/admin.groups.duplicate.spec.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { post } = vi.hoisted(() => ({ + post: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { post } +})) + +import { duplicate } from '@/api/admin/groups' + +describe('admin group duplicate API', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + localStorage.setItem('auth_user', JSON.stringify({ id: 7 })) + post.mockReset() + post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)', status: 'inactive' } }) + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends a stable idempotency key with the duplicate request', async () => { + const group = await duplicate(42) + + expect(post).toHaveBeenCalledWith('/admin/groups/42/duplicate', undefined, { + headers: { + 'Idempotency-Key': 'group-duplicate-7-42-11111111-1111-4111-8111-111111111111' + } + }) + expect(group).toEqual({ id: 43, name: 'primary (Copy)', status: 'inactive' }) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after an ambiguous failed request', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(99)).rejects.toThrow('network timeout') + + post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } }) + await duplicate(99) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(post.mock.calls[0][2].headers) + expect(sessionStorage.length).toBe(0) + }) + + it('reuses the operation key after a page reload', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(77)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.resetModules() + post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } }) + const { duplicate: duplicateAfterReload } = await import('@/api/admin/groups') + await duplicateAfterReload(77) + + expect(post).toHaveBeenCalledTimes(2) + expect(post.mock.calls[1][2].headers).toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) + + it('does not reuse an operation key across administrators for the same group', async () => { + post.mockRejectedValueOnce(new Error('first admin timeout')) + await expect(duplicate(55)).rejects.toThrow('first admin timeout') + const firstAdminHeaders = post.mock.calls[0][2].headers + + localStorage.setItem('auth_user', JSON.stringify({ id: 8 })) + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '22222222-2222-4222-8222-222222222222' + ) + post.mockResolvedValueOnce({ data: { id: 56, name: 'second admin copy' } }) + await duplicate(55) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstAdminHeaders) + expect(post.mock.calls[1][2].headers).toEqual({ + 'Idempotency-Key': 'group-duplicate-8-55-22222222-2222-4222-8222-222222222222' + }) + expect(sessionStorage.getItem('sub2api:admin:group-duplicate:7:55')).toBe( + firstAdminHeaders['Idempotency-Key'] + ) + expect(sessionStorage.getItem('sub2api:admin:group-duplicate:8:55')).toBeNull() + }) + + it('does not persist or reuse keys when the current user cannot be parsed', async () => { + localStorage.setItem('auth_user', '{invalid json') + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(66)).rejects.toThrow('network timeout') + const firstHeaders = post.mock.calls[0][2].headers + + vi.mocked(globalThis.crypto.randomUUID).mockReturnValueOnce( + '33333333-3333-4333-8333-333333333333' + ) + post.mockResolvedValueOnce({ data: { id: 67, name: 'fallback copy' } }) + await duplicate(66) + + expect(post.mock.calls[1][2].headers).not.toEqual(firstHeaders) + expect(sessionStorage.length).toBe(0) + }) +}) diff --git a/frontend/src/api/__tests__/admin.users.spec.ts b/frontend/src/api/__tests__/admin.users.spec.ts index 37656b78a..b70170b91 100644 --- a/frontend/src/api/__tests__/admin.users.spec.ts +++ b/frontend/src/api/__tests__/admin.users.spec.ts @@ -11,9 +11,12 @@ vi.mock('@/api/client', () => ({ })) import { + batchUpdateLimits, bindUserAuthIdentity, type AdminBindAuthIdentityRequest, type AdminBoundAuthIdentity, + type BatchUpdateUserLimitsRequest, + type BatchUpdateUserLimitsResponse, } from '@/api/admin/users' type Assert = T @@ -63,6 +66,20 @@ const requestContractExact: Assert< const responseContractExact: Assert< IsExact > = true +const batchRequestContractExact: Assert< + IsExact< + BatchUpdateUserLimitsRequest, + { + user_ids: number[] + all?: boolean + concurrency?: number + rpm_limit?: number + } + > +> = true +const batchResponseContractExact: Assert< + IsExact +> = true describe('admin users api auth identity binding', () => { beforeEach(() => { @@ -114,4 +131,20 @@ describe('admin users api auth identity binding', () => { expect(requestContractExact).toBe(true) expect(responseContractExact).toBe(true) }) + + it('posts batch limit updates once with only the supplied limit fields', async () => { + const request: BatchUpdateUserLimitsRequest = { + user_ids: [4, 7], + all: false, + rpm_limit: 0, + } + post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse }) + + const result = await batchUpdateLimits(request) + + expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request) + expect(result).toEqual({ affected: 2 }) + expect(batchRequestContractExact).toBe(true) + expect(batchResponseContractExact).toBe(true) + }) }) diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index de605351e..5eb78b798 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -156,6 +156,88 @@ export async function create(params: CreateParams): Promise { return data } +/** + * Duplicate a monitor without exposing its stored API key to the browser. + * Keep the operation key after ambiguous failures so a retry replays the + * original server-side operation instead of creating another monitor. + */ +const duplicateOperationKeys = new Map() + +interface DuplicateOperationScope { + adminID: string + key: string +} + +function getCurrentAdminID(): string | null { + try { + const rawUser = globalThis.localStorage?.getItem('auth_user') + if (!rawUser) return null + + const user: unknown = JSON.parse(rawUser) + if (typeof user !== 'object' || user === null) return null + + const id = (user as { id?: unknown }).id + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null + return String(id) + } catch { + return null + } +} + +function duplicateOperationScope(id: number): DuplicateOperationScope | null { + const adminID = getCurrentAdminID() + if (!adminID) return null + + return { + adminID, + key: `sub2api:admin:channel-monitor-duplicate:${adminID}:${id}`, + } +} + +function getStoredDuplicateOperationKey(storageKey: string): string | null { + try { + return globalThis.sessionStorage?.getItem(storageKey) ?? null + } catch { + return null + } +} + +function storeDuplicateOperationKey(storageKey: string, key: string | null): void { + try { + if (key) globalThis.sessionStorage?.setItem(storageKey, key) + else globalThis.sessionStorage?.removeItem(storageKey) + } catch { + // In-memory retry protection still works when browser storage is unavailable. + } +} + +export async function duplicate(id: number): Promise { + const scope = duplicateOperationScope(id) + let idempotencyKey = scope + ? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key) + : null + if (!idempotencyKey) { + const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + idempotencyKey = `channel-monitor-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}` + } + if (scope) { + duplicateOperationKeys.set(scope.key, idempotencyKey) + storeDuplicateOperationKey(scope.key, idempotencyKey) + } + + const { data } = await apiClient.post( + `/admin/channel-monitors/${id}/duplicate`, + undefined, + { headers: { 'Idempotency-Key': idempotencyKey } } + ) + + if (scope) { + duplicateOperationKeys.delete(scope.key) + storeDuplicateOperationKey(scope.key, null) + } + return data +} + /** * Update an existing channel monitor. * api_key field: empty string means "do not modify". @@ -199,6 +281,7 @@ export const channelMonitorAPI = { list, get, create, + duplicate, update, del, runNow, diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 31a1b5765..3d2d121fb 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise return data } +/** + * Duplicate a group on the server so configuration that is not present in the + * list response is preserved. Keep the operation key after ambiguous failures + * so a retry replays the original operation instead of creating another group. + */ +const duplicateOperationKeys = new Map() + +interface DuplicateOperationScope { + adminID: string + key: string +} + +function getCurrentAdminID(): string | null { + try { + const rawUser = globalThis.localStorage?.getItem('auth_user') + if (!rawUser) return null + + const user: unknown = JSON.parse(rawUser) + if (typeof user !== 'object' || user === null) return null + + const id = (user as { id?: unknown }).id + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) return null + return String(id) + } catch { + return null + } +} + +function duplicateOperationScope(id: number): DuplicateOperationScope | null { + const adminID = getCurrentAdminID() + if (!adminID) return null + + return { + adminID, + key: `sub2api:admin:group-duplicate:${adminID}:${id}` + } +} + +function getStoredDuplicateOperationKey(storageKey: string): string | null { + try { + return globalThis.sessionStorage?.getItem(storageKey) ?? null + } catch { + return null + } +} + +function storeDuplicateOperationKey(storageKey: string, key: string | null): void { + try { + if (key) globalThis.sessionStorage?.setItem(storageKey, key) + else globalThis.sessionStorage?.removeItem(storageKey) + } catch { + // In-memory retry protection still works when browser storage is unavailable. + } +} + +export async function duplicate(id: number): Promise { + const scope = duplicateOperationScope(id) + let idempotencyKey = scope + ? duplicateOperationKeys.get(scope.key) ?? getStoredDuplicateOperationKey(scope.key) + : null + if (!idempotencyKey) { + const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + idempotencyKey = `group-duplicate-${scope?.adminID ?? 'unknown-admin'}-${id}-${requestID}` + } + if (scope) { + duplicateOperationKeys.set(scope.key, idempotencyKey) + storeDuplicateOperationKey(scope.key, idempotencyKey) + } + + const { data } = await apiClient.post(`/admin/groups/${id}/duplicate`, undefined, { + headers: { 'Idempotency-Key': idempotencyKey } + }) + + if (scope) { + duplicateOperationKeys.delete(scope.key) + storeDuplicateOperationKey(scope.key, null) + } + return data +} + /** * Update group * @param id - Group ID @@ -337,6 +417,7 @@ export const groupsAPI = { getById, getModelsListCandidates, create, + duplicate, update, delete: deleteGroup, toggleStatus, diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 78b4f8177..738e0531c 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -44,6 +44,17 @@ export interface AdminBoundAuthIdentity { channel?: AdminBoundAuthIdentityChannel | null } +export interface BatchUpdateUserLimitsRequest { + user_ids: number[] + all?: boolean + concurrency?: number + rpm_limit?: number +} + +export interface BatchUpdateUserLimitsResponse { + affected: number +} + /** * List all users with pagination * @param page - Page number (default: 1) @@ -184,6 +195,17 @@ export async function updateConcurrency(id: number, concurrency: number): Promis return update(id, { concurrency }) } +/** Overwrite concurrency and/or RPM limits for multiple users in one request. */ +export async function batchUpdateLimits( + request: BatchUpdateUserLimitsRequest +): Promise { + const { data } = await apiClient.post( + '/admin/users/batch-limits', + request + ) + return data +} + /** * Toggle user status * @param id - User ID @@ -385,6 +407,7 @@ export const usersAPI = { delete: deleteUser, updateBalance, updateConcurrency, + batchUpdateLimits, toggleStatus, getUserApiKeys, getUserUsageStats, diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index f6d941ea4..5f744c0fc 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -110,6 +110,11 @@ :placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')" aria-labelledby="bulk-edit-base-url-label" /> +

{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}

@@ -1221,6 +1226,7 @@ import { HEADER_OVERRIDES_CREDENTIAL_KEY, type HeaderOverrideRow } from '@/components/account/credentialsBuilder' +import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import { OPENAI_WS_MODE_CTX_POOL, OPENAI_WS_MODE_OFF, @@ -1260,6 +1266,12 @@ const targetMode = computed(() => props.target?.mode ?? 'selected') const targetPreviewCount = computed(() => props.target?.previewCount ?? props.accountIds.length) const targetSelectedPlatforms = computed(() => props.target?.selectedPlatforms ?? props.selectedPlatforms) const targetSelectedTypes = computed(() => props.target?.selectedTypes ?? props.selectedTypes) +// Grok 快捷端点仅在所选账号全部为 grok 平台时展示(其他平台不显示) +const allTargetsGrok = computed( + () => + targetSelectedPlatforms.value.length > 0 && + targetSelectedPlatforms.value.every((p) => p === 'grok') +) const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1) const allOpenAIPassthroughCapable = computed(() => { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 5c21f95fd..7eebce530 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1117,6 +1117,11 @@ " />

{{ baseUrlHint }}

+
@@ -1933,7 +1938,7 @@ />
-
+
+
@@ -3519,6 +3525,7 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' +import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue' import { applyAntigravityProjectID, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index eed9920bb..c61ebf605 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -47,6 +47,11 @@ " />

{{ baseUrlHint }}

+
@@ -452,7 +457,7 @@ />
-
+
+
@@ -1455,7 +1461,7 @@ - +
+
+ +
+ + + diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 31f6e3bd2..489b00632 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -130,6 +130,94 @@ describe('BulkEditAccountModal', () => { }) }) + it('全部目标为 Grok OAuth 时,官方主机 base_url 作为手动端点切换正常提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://api.x.ai/v1' + } + }) + }) + + it('所选全为 grok 时展示快捷端点,点击后填入并自动勾选 base_url', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]') + expect(presets.length).toBe(5) + + // 第三个预设为区域 API (us-east-1.api.x.ai/v1) + await presets[2].trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://us-east-1.api.x.ai/v1' + } + }) + }) + + it('所选含非 grok 平台时不展示快捷端点', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok', 'anthropic'], + selectedTypes: ['apikey'] + }) + + expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0) + }) + + it('全部目标为 Grok OAuth 时,第三方 base_url 正常提交', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://relay.example.com/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://relay.example.com/v1' + } + }) + }) + + it('混合类型选择(含 apikey)时官方主机 base_url 不拦截', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['grok'], + selectedTypes: ['apikey', 'oauth'] + }) + + await wrapper.get('#bulk-edit-base-url-enabled').setValue(true) + await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + base_url: 'https://api.x.ai/v1' + } + }) + }) + it('OpenAI 账号批量编辑可开启自动透传', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts new file mode 100644 index 000000000..310a37088 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({ + updateAccountMock: vi.fn(), + checkMixedChannelRiskMock: vi.fn(), + authIsSimpleMode: { value: true } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn() + }) +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + get isSimpleMode() { + return authIsSimpleMode.value + } + }) +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + update: updateAccountMock, + checkMixedChannelRisk: checkMixedChannelRiskMock + }, + settings: { + getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), + getSettings: vi.fn().mockResolvedValue({}) + }, + tlsFingerprintProfiles: { + list: vi.fn().mockResolvedValue([]) + } + } +})) + +vi.mock('@/api/admin/accounts', () => ({ + getAntigravityDefaultModelMapping: vi.fn() +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + name: 'BaseDialog', + props: { + show: { + type: Boolean, + default: false + } + }, + template: '
' +}) + +function buildGrokOAuthAccount(credentials: Record = {}) { + return { + id: 5, + name: 'Grok OAuth', + notes: '', + platform: 'grok', + type: 'oauth', + credentials: { + expires_at: '2027-01-01T00:00:00Z', + token_type: 'Bearer', + ...credentials + }, + credentials_status: { has_access_token: true, has_refresh_token: true }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + +function mountModal(account: any) { + return mount(EditAccountModal, { + props: { + show: true, + account, + proxies: [], + groups: [] + }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Select: true, + Icon: true, + ProxySelector: true, + GroupSelector: true, + ModelWhitelistSelector: true + } + } + }) +} + +describe('EditAccountModal Grok OAuth upstream config', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + }) + + it('enabling the custom base URL toggle and saving persists base_url', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + // 官方地址 → 开关初始为关(视同未定制) + const toggle = wrapper.get('[data-testid="grok-custom-base-url-toggle"]') + await toggle.trigger('click') + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + await input.setValue('https://my-relay.example.com') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com') + }) + + it('accepts the official API host as a manual endpoint switch and persists it', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click') + await wrapper.get('[data-testid="grok-custom-base-url-input"]').setValue('https://api.x.ai/v1') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://api.x.ai/v1') + }) + + it('echoes a stored official API endpoint with the toggle on', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://us-west-2.api.x.ai/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://us-west-2.api.x.ai/v1') + }) + + it('fills the input from an endpoint preset chip', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click') + const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]') + expect(presets.length).toBe(5) + + // 第二个预设为官方 API (api.x.ai/v1) + await presets[1].trigger('click') + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://api.x.ai/v1') + }) + + it('loads an existing custom base_url with the toggle on and keeps it on save', async () => { + const account = buildGrokOAuthAccount({ base_url: 'https://my-relay.example.com' }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + const input = wrapper.get('[data-testid="grok-custom-base-url-input"]') + expect((input.element as HTMLInputElement).value).toBe('https://my-relay.example.com') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com') + }) + + it('keeps stored header overrides intact on an untouched save', async () => { + const account = buildGrokOAuthAccount({ + header_override_enabled: true, + header_overrides: { + 'user-agent': 'grok-pager/0.2.93', + 'x-grok-client-identifier': 'grok-pager', + 'x-grok-client-version': '0.2.93', + 'x-xai-token-auth': 'xai-grok-cli' + } + }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1)) + + const payload = updateAccountMock.mock.calls[0]?.[1] + expect(payload?.credentials?.header_override_enabled).toBe(true) + expect(payload?.credentials?.header_overrides).toEqual({ + 'user-agent': 'grok-pager/0.2.93', + 'x-grok-client-identifier': 'grok-pager', + 'x-grok-client-version': '0.2.93', + 'x-xai-token-auth': 'xai-grok-cli' + }) + }) +}) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 98de8f0c0..8cd20e89e 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -771,6 +771,10 @@ describe('EditAccountModal', () => { const wrapper = mountModal(account) + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageTool') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolDesc') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolEnabledDesc') + await wrapper.get('button[data-testid="codex-image-tool-enabled"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') @@ -809,6 +813,9 @@ describe('EditAccountModal', () => { const wrapper = mountModal(account) + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlock') + expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlockDesc') + await wrapper.get('button[data-testid="codex-image-tool-block"]').trigger('click') await wrapper.get('form#edit-account-form').trigger('submit.prevent') diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index 207cacc3d..6d4b59e2a 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -11,6 +11,7 @@ import { buildPlanTypeOptions, isCustomGrokBaseUrl, isHeaderOverrideCapable, + GROK_BASE_URL_PRESETS, parseHeaderOverridesJson, planTypeDisplayLabel, readPlanType, @@ -169,11 +170,15 @@ describe('serializeHeaderOverrideRows', () => { }) describe('isCustomGrokBaseUrl', () => { - it('treats official hosts and their variants as not customized', () => { - expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false) + it('treats only the default CLI gateway host as not customized', () => { expect(isCustomGrokBaseUrl('https://cli-chat-proxy.grok.com/v1')).toBe(false) - expect(isCustomGrokBaseUrl('HTTPS://API.X.AI:443/')).toBe(false) - expect(isCustomGrokBaseUrl('https://api.x.ai:8443/v1')).toBe(false) + expect(isCustomGrokBaseUrl('HTTPS://CLI-CHAT-PROXY.GROK.COM:443/')).toBe(false) + }) + + it('treats manually switched official/regional endpoints as customized (must echo back)', () => { + expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(true) + expect(isCustomGrokBaseUrl('https://us-west-2.api.x.ai/v1')).toBe(true) + expect(isCustomGrokBaseUrl('https://eu-west-1.api.x.ai/v1')).toBe(true) }) it('treats empty, non-string and unparseable values as not customized', () => { @@ -191,6 +196,30 @@ describe('isCustomGrokBaseUrl', () => { }) }) +describe('GROK_BASE_URL_PRESETS', () => { + it('covers the CLI gateway, official API and regional endpoints', () => { + const urls = GROK_BASE_URL_PRESETS.map((p) => p.url) + expect(urls).toEqual([ + 'https://cli-chat-proxy.grok.com/v1', + 'https://api.x.ai/v1', + 'https://us-east-1.api.x.ai/v1', + 'https://us-west-2.api.x.ai/v1', + 'https://eu-west-1.api.x.ai/v1' + ]) + for (const preset of GROK_BASE_URL_PRESETS) { + // 每个预设要么有 i18n 标签键,要么有区域标识等字面标签 + expect(Boolean(preset.labelKey) || Boolean(preset.label)).toBe(true) + if (preset.labelKey) { + expect(['cli', 'official']).toContain(preset.labelKey) + } + } + // 区域端点用区域标识作字面标签(us-east-1 这样的专有名词不做 i18n) + expect(GROK_BASE_URL_PRESETS[2].label).toBe('us-east-1') + expect(GROK_BASE_URL_PRESETS[3].label).toBe('us-west-2') + expect(GROK_BASE_URL_PRESETS[4].label).toBe('eu-west-1') + }) +}) + describe('validateHeaderOverrideRows', () => { it('accepts valid rows and empty placeholder rows', () => { expect( diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 32726a43d..322141525 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -199,12 +199,15 @@ export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string { // ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ========== -const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com']) +/** OAuth 账号建号/刷新默认写入的 CLI 网关 host——只有它视同"未定制"。 */ +const GROK_DEFAULT_GATEWAY_HOST = 'cli-chat-proxy.grok.com' /** - * 判断 Grok 账号存储的 base_url 是否为自定义转发地址。 - * 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐), - * 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。 + * 判断 Grok 账号存储的 base_url 是否为主动指定的上游端点。 + * 运营方可在官方 API / 区域 API / 第三方转发地址之间手动切换(应对单端点 + * 不可用),这些值都必须回显(开关开启 + 显示地址)。仅默认 CLI 网关 + * (建号/刷新自动写入)、空值与无法解析的值视为"未定制"(与后端 + * GetGrokBaseURL 的回落语义对齐),用于 OAuth 账号编辑时决定开关初始状态。 */ export function isCustomGrokBaseUrl(value: unknown): boolean { if (typeof value !== 'string') return false @@ -216,9 +219,29 @@ export function isCustomGrokBaseUrl(value: unknown): boolean { } catch { return false } - return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase()) + return parsed.hostname.toLowerCase() !== GROK_DEFAULT_GATEWAY_HOST } +export interface GrokBaseUrlPreset { + /** i18n 子键:admin.accounts.grokCustomBaseUrl.presets. */ + labelKey?: 'cli' | 'official' + /** 字面标签(如区域标识 us-east-1),专有名词不参与 i18n */ + label?: string + url: string +} + +/** + * Grok 快捷端点(仅供快速填充,输入框仍可自由填写任意转发地址)。 + * 官方端点偶发不可用时,运营方靠这组预设在端点间手动切换。 + */ +export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ + { labelKey: 'cli', url: 'https://cli-chat-proxy.grok.com/v1' }, + { labelKey: 'official', url: 'https://api.x.ai/v1' }, + { label: 'us-east-1', url: 'https://us-east-1.api.x.ai/v1' }, + { label: 'us-west-2', url: 'https://us-west-2.api.x.ai/v1' }, + { label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' } +] + /** * 将请求头覆写写入 credentials。 * create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。 diff --git a/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts new file mode 100644 index 000000000..31f9eca73 --- /dev/null +++ b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import type { ChannelMonitor } from '@/api/admin/channelMonitor' +import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +function makeMonitor(overrides: Partial = {}): ChannelMonitor { + return { + id: 42, + name: 'primary', + provider: 'openai', + api_mode: 'chat_completions', + endpoint: 'https://api.example.com', + api_key_masked: 'sk-t***', + primary_model: 'gpt-4o-mini', + extra_models: [], + group_name: '', + enabled: true, + interval_seconds: 60, + jitter_seconds: 0, + last_checked_at: null, + created_by: 1, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + primary_status: '', + primary_latency_ms: null, + availability_7d: 0, + extra_models_status: [], + template_id: null, + extra_headers: {}, + body_override_mode: 'off', + body_override: null, + ...overrides, + } +} + +describe('MonitorActionsCell duplicate action', () => { + it('emits the selected monitor when duplicate is clicked', async () => { + const row = makeMonitor() + const wrapper = mount(MonitorActionsCell, { + props: { row, running: false, duplicating: false }, + }) + + await wrapper.get('[data-testid="monitor-duplicate"]').trigger('click') + + expect(wrapper.emitted('duplicate')).toEqual([[row]]) + }) + + it('disables the action while the same monitor is being duplicated', () => { + const wrapper = mount(MonitorActionsCell, { + props: { row: makeMonitor(), running: false, duplicating: true }, + }) + const button = wrapper.get('[data-testid="monitor-duplicate"]') + + expect(button.attributes('disabled')).toBeDefined() + expect(button.attributes('title')).toBe('admin.channelMonitor.duplicating') + expect(button.text()).toContain('admin.channelMonitor.duplicating') + }) + + it('disables the action when the stored API key cannot be decrypted', () => { + const wrapper = mount(MonitorActionsCell, { + props: { + row: makeMonitor({ api_key_decrypt_failed: true }), + running: false, + duplicating: false, + }, + }) + const button = wrapper.get('[data-testid="monitor-duplicate"]') + + expect(button.attributes('disabled')).toBeDefined() + expect(button.attributes('title')).toBe('admin.channelMonitor.duplicateKeyUnavailable') + }) +}) diff --git a/frontend/src/components/admin/monitor/MonitorActionsCell.vue b/frontend/src/components/admin/monitor/MonitorActionsCell.vue index 74aa40172..324af8ed3 100644 --- a/frontend/src/components/admin/monitor/MonitorActionsCell.vue +++ b/frontend/src/components/admin/monitor/MonitorActionsCell.vue @@ -8,6 +8,18 @@ {{ t('admin.channelMonitor.runNow') }} +
+ + + diff --git a/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts new file mode 100644 index 000000000..1b0277e16 --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import BulkEditUserModal from '../BulkEditUserModal.vue' + +const { batchUpdateLimits, showSuccess, showError } = vi.hoisted(() => ({ + batchUpdateLimits: vi.fn(), + showSuccess: vi.fn(), + showError: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + users: { + batchUpdateLimits + } + } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showSuccess, + showError + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key + }) +})) + +const mountModal = () => mount(BulkEditUserModal, { + props: { + show: true, + selectedIds: [4, 7] + }, + global: { + stubs: { + BaseDialog: { + props: ['show', 'title'], + emits: ['close'], + template: '
' + } + } + } +}) + +describe('BulkEditUserModal', () => { + beforeEach(() => { + batchUpdateLimits.mockReset() + showSuccess.mockReset() + showError.mockReset() + batchUpdateLimits.mockResolvedValue({ affected: 2 }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('disables submission until at least one enabled field has a value', async () => { + const wrapper = mountModal() + + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="concurrency-input"]').setValue('5') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined() + }) + + it('disables submission when more than 500 users are selected', async () => { + const wrapper = mountModal() + await wrapper.setProps({ selectedIds: Array.from({ length: 501 }, (_, index) => index + 1) }) + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('5') + + expect(wrapper.text()).toContain('admin.users.bulkLimits.selectionLimit') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + }) + + it('submits only the enabled RPM field and preserves zero as unlimited', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-rpm-limit"]').trigger('click') + await wrapper.get('[data-test="rpm-limit-input"]').setValue('0') + expect(wrapper.text()).toContain('admin.users.bulkLimits.unlimited') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).toHaveBeenCalledWith({ + user_ids: [4, 7], + all: false, + rpm_limit: 0 + }) + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining('admin.users.bulkLimits.rpmUnlimitedValue') + ) + expect(wrapper.emitted('success')).toEqual([[2]]) + }) + + it('omits disabled fields from the request', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('9') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).toHaveBeenCalledWith({ + user_ids: [4, 7], + all: false, + concurrency: 9 + }) + }) + + it('does not call the API when overwrite confirmation is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('9') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index b28f0d059..9dcca93c9 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -32,14 +32,41 @@