Merge origin/main into codex/fix-grok-regressions-4412-4421

Reconcile the OAuth media route with the manual endpoint-switch redesign
(7f5d067af): media leaves for api.x.ai only when text traffic resolves to
the CLI gateway host; manually selected official/regional/custom endpoints
keep serving media as-is.
This commit is contained in:
shaw committed 2026-07-16 19:57:48 +08:00
commit c1fd1cb44e
135 files changed
+6612 -546

No files matched your search

+12
View File
@@ -169,6 +169,18 @@ Model authenticity: no content intervention or secondary filtering — experienc
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 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 <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 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 <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
</td>
</tr>
</table>
## Overview
+12
View File
@@ -172,6 +172,18 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
</td>
</tr>
</table>
## 项目概述
+12
View File
@@ -167,6 +167,18 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
</td>
</tr>
</table>
## 概要
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.156
0.1.157
+3 -2
View File
@@ -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)
+15 -1
View File
@@ -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(", ")
+10
View File
@@ -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()
+80
View File
@@ -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))
+29
View File
@@ -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
+6
View File
@@ -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)
}
+12 -3
View File
@@ -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",
},
},
},
}
+74 -1
View File
@@ -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
+27 -23
View File
@@ -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()
+10
View File
@@ -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")),
}
}
@@ -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)
@@ -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
@@ -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")
}
@@ -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)
@@ -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
@@ -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)
@@ -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) {
@@ -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)
}
@@ -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) {
@@ -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)
@@ -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
}
@@ -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
}
@@ -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()
@@ -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)
}
@@ -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
@@ -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,
+11 -1
View File
@@ -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)
}
@@ -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
+4 -3
View File
@@ -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 {
+23 -3
View File
@@ -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 {
+20
View File
@@ -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)
@@ -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,
@@ -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")
}
@@ -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 {
@@ -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)
}
@@ -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,
))
}
@@ -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 个配置」用)。
+101 -7
View File
@@ -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) {
@@ -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)
}
@@ -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")
+33
View File
@@ -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)
}
@@ -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"})
+1
View File
@@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet(
NewUserRepository,
NewAPIKeyRepository,
NewGroupRepository,
NewAdminGroupRepository,
NewAccountRepository,
NewAdminAccountRepository,
NewScheduledTestPlanRepository, // 定时测试计划仓储
@@ -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
}
@@ -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")
+48 -34
View File
@@ -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
}
@@ -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())
}
+3
View File
@@ -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)
+15 -15
View File
@@ -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 {
@@ -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{
@@ -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
}
}
}
@@ -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)
}
+10 -1
View File
@@ -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,
@@ -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")
}
@@ -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
}
@@ -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 {
@@ -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 }
+33
View File
@@ -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 {
@@ -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
@@ -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)
}
@@ -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 {
@@ -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
@@ -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")
@@ -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)
@@ -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
}
@@ -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)
+7 -9
View File
@@ -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 {
@@ -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(如果未含)
//
@@ -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) {
+3
View File
@@ -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
+16
View File
@@ -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"`
@@ -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)
@@ -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,
@@ -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) {
@@ -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 {
@@ -803,6 +803,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
wroteDownstream,
)
}
if normalized, changed := normalizeCompletedImageGenerationStatus(upstreamMessage); changed {
upstreamMessage = normalized
}
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(upstreamMessage)
if responseID == "" && eventResponseID != "" {
@@ -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")
@@ -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 {
@@ -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 == "" {
@@ -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
@@ -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)
}
+39 -39
View File
@@ -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
@@ -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)
}
+1
View File
@@ -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(幂等,冲突忽略)
@@ -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)
@@ -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;
@@ -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)
})
})
@@ -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)
})
})
@@ -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 extends true> = T
@@ -63,6 +66,20 @@ const requestContractExact: Assert<
const responseContractExact: Assert<
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
> = true
const batchRequestContractExact: Assert<
IsExact<
BatchUpdateUserLimitsRequest,
{
user_ids: number[]
all?: boolean
concurrency?: number
rpm_limit?: number
}
>
> = true
const batchResponseContractExact: Assert<
IsExact<BatchUpdateUserLimitsResponse, { affected: number }>
> = 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)
})
})
+83
View File
@@ -156,6 +156,88 @@ export async function create(params: CreateParams): Promise<ChannelMonitor> {
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<string, string>()
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<ChannelMonitor> {
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<ChannelMonitor>(
`/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,
+81
View File
@@ -114,6 +114,86 @@ export async function create(groupData: CreateGroupRequest): Promise<AdminGroup>
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<string, string>()
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<AdminGroup> {
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<AdminGroup>(`/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,
+23
View File
@@ -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<BatchUpdateUserLimitsResponse> {
const { data } = await apiClient.post<BatchUpdateUserLimitsResponse>(
'/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,
@@ -110,6 +110,11 @@
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
aria-labelledby="bulk-edit-base-url-label"
/>
<GrokBaseUrlPresets
v-if="allTargetsGrok"
class="mt-2"
@select="baseUrl = $event; enableBaseUrl = true"
/>
<p class="input-hint">
{{ t('admin.accounts.bulkEdit.baseUrlNotice') }}
</p>
@@ -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(() => {
@@ -1117,6 +1117,11 @@
"
/>
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
<GrokBaseUrlPresets
v-if="form.platform === 'grok'"
class="mt-2"
@select="apiKeyBaseUrl = $event"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
@@ -1933,7 +1938,7 @@
/>
</button>
</div>
<div v-if="grokOAuthCustomBaseUrlEnabled">
<div v-if="grokOAuthCustomBaseUrlEnabled" class="space-y-2">
<input
v-model="grokOAuthBaseUrl"
type="text"
@@ -1941,6 +1946,7 @@
data-testid="grok-custom-base-url-input"
:placeholder="t('admin.accounts.grokCustomBaseUrl.placeholder')"
/>
<GrokBaseUrlPresets @select="grokOAuthBaseUrl = $event" />
</div>
</div>
@@ -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,
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more