Ambiguous idempotency-store failures can occur after the account transaction commits. Scope durable recovery markers to the authenticated admin, retain the operation key across reloads, and recover only an already committed copy without rerunning active work. Constraint: Generic idempotent handlers may legitimately remain active while a recovery lookup is attempted Rejected: Reclaim or rerun an in-progress duplicate request | can execute account creation concurrently Rejected: Recover by source account and key alone | allows another admin to observe the committed copy Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep ambiguous-response recovery read-only and bind durable operation markers to the authenticated actor Tested: Full Go unit suite, go vet, server build, integration-test compilation; frontend lint, typecheck, 1,030 Vitest tests, and production build Not-tested: Docker-backed PostgreSQL integration runtime because Docker is unavailable Related: Wei-Shaw/sub2api#1379 Related: Wei-Shaw/sub2api#2928
651 lines
28 KiB
Go
651 lines
28 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"time"
|
||
|
||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
)
|
||
|
||
// AdminService interface defines admin management operations
|
||
type AdminService interface {
|
||
// User management
|
||
ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error)
|
||
GetUser(ctx context.Context, id int64) (*User, error)
|
||
GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error)
|
||
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
|
||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
||
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)
|
||
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)
|
||
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
|
||
// codeType is optional - pass empty string to return all types.
|
||
// Also returns totalRecharged (sum of all positive balance top-ups).
|
||
GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error)
|
||
BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error)
|
||
|
||
// Group management
|
||
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error)
|
||
GetAllGroups(ctx context.Context) ([]Group, error)
|
||
GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error)
|
||
// GetAllGroupsIncludingInactive returns all groups regardless of status (active + disabled),
|
||
// ordered by sort_order then id. Used by the API Key group filter dropdown.
|
||
GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error)
|
||
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)
|
||
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)
|
||
GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
|
||
ClearGroupRateMultipliers(ctx context.Context, groupID int64) error
|
||
BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
|
||
ClearGroupRPMOverrides(ctx context.Context, groupID int64) error
|
||
BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error
|
||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||
|
||
// API Key management (admin)
|
||
AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error)
|
||
AdminResetAPIKeyRateLimitUsage(ctx context.Context, keyID int64) (*APIKey, error)
|
||
|
||
// ReplaceUserGroup 替换用户的专属分组:授予新分组权限、迁移 Key、移除旧分组权限
|
||
ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error)
|
||
|
||
// Account management
|
||
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error)
|
||
// ListAccountsForSchedulerScoreFilter 返回符合过滤条件的全部账号(不分页),
|
||
// 作为账号列表页计算 OpenAI 调度分数的过滤范围池。
|
||
ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error)
|
||
// ListOpenAISchedulableAccountsForSchedulerScore 返回指定分组(nil 为未分组)内
|
||
// 可调度的 OpenAI 账号,用于按组计算调度分数。
|
||
ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error)
|
||
GetAccount(ctx context.Context, id int64) (*Account, error)
|
||
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
||
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
||
// DuplicateAccount creates an independent account from an existing account's configuration.
|
||
// First-class runtime columns are intentionally reset by the normal account creation path.
|
||
DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error)
|
||
// RecoverDuplicateAccount returns a previously committed duplicate for an ambiguous retry.
|
||
// It never creates an account.
|
||
RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error)
|
||
UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error)
|
||
// UpdateAccountExtra 仅对 Extra 做 JSONB 增量合并(key 级覆盖),不会影响其它字段或运行态键。
|
||
// 用于刷新流程持久化 account_uuid / org_uuid 等少量键,避免被全量快照覆盖。
|
||
UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error
|
||
DeleteAccount(ctx context.Context, id int64) error
|
||
RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error)
|
||
ClearAccountError(ctx context.Context, id int64) (*Account, error)
|
||
SetAccountError(ctx context.Context, id int64, errorMsg string) error
|
||
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
|
||
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
|
||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。
|
||
EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
|
||
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
|
||
ForceOpenAIPrivacy(ctx context.Context, account *Account) string
|
||
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
|
||
ForceAntigravityPrivacy(ctx context.Context, account *Account) string
|
||
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
|
||
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
|
||
// RevertAccountProxyFallback 将账号的 proxy_id 切回 proxy_fallback_origin_id,并清空 origin 字段。
|
||
// 若账号不存在返回 ErrAccountNotFound;若账号存在但不在 fallback 状态,返回 ErrAccountNotInFallback。
|
||
RevertAccountProxyFallback(ctx context.Context, id int64) error
|
||
// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。
|
||
// 影子账号不持凭据(Credentials 恒为空),透传母账号凭据;继承母账号的 ProxyID。
|
||
CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error)
|
||
|
||
// Proxy management
|
||
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error)
|
||
ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error)
|
||
GetAllProxies(ctx context.Context) ([]Proxy, error)
|
||
GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error)
|
||
GetProxy(ctx context.Context, id int64) (*Proxy, error)
|
||
GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error)
|
||
CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error)
|
||
UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error)
|
||
DeleteProxy(ctx context.Context, id int64) error
|
||
BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error)
|
||
GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error)
|
||
CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error)
|
||
TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error)
|
||
CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error)
|
||
|
||
// Redeem code management
|
||
ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error)
|
||
GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
|
||
GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error)
|
||
DeleteRedeemCode(ctx context.Context, id int64) error
|
||
BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error)
|
||
ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
|
||
ResetAccountQuota(ctx context.Context, id int64) error
|
||
}
|
||
|
||
// CreateUserInput represents input for creating a new user via admin operations.
|
||
type CreateUserInput struct {
|
||
Email string
|
||
Password string
|
||
Username string
|
||
Notes string
|
||
Role string // 空字符串表示使用默认角色(user);合法值 admin/user
|
||
Balance *float64
|
||
Concurrency int
|
||
RPMLimit int
|
||
AllowedGroups []int64
|
||
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
|
||
ActorAdminID int64
|
||
}
|
||
|
||
type UpdateUserInput struct {
|
||
Email string
|
||
Password string
|
||
Username *string
|
||
Notes *string
|
||
Role string // 空字符串表示"未提供"(不修改);合法值 admin/user
|
||
Balance *float64 // 使用指针区分"未提供"和"设置为0"
|
||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||
RPMLimit *int // 使用指针区分"未提供"和"设置为0"
|
||
Status string
|
||
AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组"
|
||
// GroupRates 用户专属分组倍率配置
|
||
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||
GroupRates map[int64]*float64
|
||
// ActorAdminID 执行本次操作的管理员ID(来自JWT),仅用于权限敏感操作的审计日志。
|
||
ActorAdminID int64
|
||
}
|
||
|
||
type AdminBindAuthIdentityInput struct {
|
||
ProviderType string
|
||
ProviderKey string
|
||
ProviderSubject string
|
||
Issuer *string
|
||
Metadata map[string]any
|
||
Channel *AdminBindAuthIdentityChannelInput
|
||
}
|
||
|
||
type AdminBindAuthIdentityChannelInput struct {
|
||
Channel string
|
||
ChannelAppID string
|
||
ChannelSubject string
|
||
Metadata map[string]any
|
||
}
|
||
|
||
type AdminBoundAuthIdentity struct {
|
||
UserID int64 `json:"user_id"`
|
||
ProviderType string `json:"provider_type"`
|
||
ProviderKey string `json:"provider_key"`
|
||
ProviderSubject string `json:"provider_subject"`
|
||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||
Issuer *string `json:"issuer,omitempty"`
|
||
Metadata map[string]any `json:"metadata"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
Channel *AdminBoundAuthIdentityChannel `json:"channel,omitempty"`
|
||
}
|
||
|
||
type AdminBoundAuthIdentityChannel struct {
|
||
Channel string `json:"channel"`
|
||
ChannelAppID string `json:"channel_app_id"`
|
||
ChannelSubject string `json:"channel_subject"`
|
||
Metadata map[string]any `json:"metadata"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
type CreateGroupInput struct {
|
||
Name string
|
||
Description string
|
||
Platform string
|
||
RateMultiplier float64
|
||
IsExclusive bool
|
||
SubscriptionType string // standard/subscription
|
||
DailyLimitUSD *float64 // 日限额 (USD)
|
||
WeeklyLimitUSD *float64 // 周限额 (USD)
|
||
MonthlyLimitUSD *float64 // 月限额 (USD)
|
||
// 图片生成计费配置(仅 antigravity 平台使用)
|
||
AllowImageGeneration bool
|
||
AllowBatchImageGeneration bool
|
||
ImageRateIndependent bool
|
||
ImageRateMultiplier *float64
|
||
BatchImageDiscountMultiplier *float64
|
||
BatchImageHoldMultiplier *float64
|
||
VideoRateIndependent bool
|
||
VideoRateMultiplier *float64
|
||
// 高峰时段倍率配置(PeakRateMultiplier 为 nil 时按 1.0 处理)
|
||
PeakRateEnabled bool
|
||
PeakStart string
|
||
PeakEnd string
|
||
PeakRateMultiplier *float64
|
||
ImagePrice1K *float64
|
||
ImagePrice2K *float64
|
||
ImagePrice4K *float64
|
||
VideoPrice480P *float64
|
||
VideoPrice720P *float64
|
||
VideoPrice1080P *float64
|
||
// Codex alpha/search 网页搜索单次价格(USD/次,仅 openai 平台使用);nil/负数按默认价 0.01 处理
|
||
WebSearchPricePerCall *float64
|
||
ClaudeCodeOnly bool // 仅允许 Claude Code 客户端
|
||
FallbackGroupID *int64 // 降级分组 ID
|
||
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
||
FallbackGroupIDOnInvalidRequest *int64
|
||
// 模型路由配置(仅 anthropic 平台使用)
|
||
ModelRouting map[string][]int64
|
||
ModelRoutingEnabled bool // 是否启用模型路由
|
||
MCPXMLInject *bool
|
||
// 支持的模型系列(仅 antigravity 平台使用)
|
||
SupportedModelScopes []string
|
||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||
AllowMessagesDispatch bool
|
||
DefaultMappedModel string
|
||
RequireOAuthOnly bool
|
||
RequirePrivacySet bool
|
||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
|
||
ModelsListConfig GroupModelsListConfig
|
||
// RPMLimit 分组 RPM 上限(0 = 不限制)
|
||
RPMLimit int
|
||
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
||
CopyAccountsFromGroupIDs []int64
|
||
}
|
||
|
||
type UpdateGroupInput struct {
|
||
Name string
|
||
Description *string
|
||
Platform string
|
||
RateMultiplier *float64 // 使用指针以支持设置为0
|
||
IsExclusive *bool
|
||
Status string
|
||
SubscriptionType string // standard/subscription
|
||
DailyLimitUSD *float64 // 日限额 (USD)
|
||
WeeklyLimitUSD *float64 // 周限额 (USD)
|
||
MonthlyLimitUSD *float64 // 月限额 (USD)
|
||
// 图片生成计费配置(仅 antigravity 平台使用)
|
||
AllowImageGeneration *bool
|
||
AllowBatchImageGeneration *bool
|
||
ImageRateIndependent *bool
|
||
ImageRateMultiplier *float64
|
||
BatchImageDiscountMultiplier *float64
|
||
BatchImageHoldMultiplier *float64
|
||
VideoRateIndependent *bool
|
||
VideoRateMultiplier *float64
|
||
// 高峰时段倍率配置(nil 表示不修改)
|
||
PeakRateEnabled *bool
|
||
PeakStart *string
|
||
PeakEnd *string
|
||
PeakRateMultiplier *float64
|
||
ImagePrice1K *float64
|
||
ImagePrice2K *float64
|
||
ImagePrice4K *float64
|
||
VideoPrice480P *float64
|
||
VideoPrice720P *float64
|
||
VideoPrice1080P *float64
|
||
// Codex alpha/search 网页搜索单次价格(USD/次);nil 表示不修改,负数表示清除回默认价 0.01
|
||
WebSearchPricePerCall *float64
|
||
ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端
|
||
FallbackGroupID *int64 // 降级分组 ID
|
||
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
|
||
FallbackGroupIDOnInvalidRequest *int64
|
||
// 模型路由配置(仅 anthropic 平台使用)
|
||
ModelRouting map[string][]int64
|
||
ModelRoutingEnabled *bool // 是否启用模型路由
|
||
MCPXMLInject *bool
|
||
// 支持的模型系列(仅 antigravity 平台使用)
|
||
SupportedModelScopes *[]string
|
||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||
AllowMessagesDispatch *bool
|
||
DefaultMappedModel *string
|
||
RequireOAuthOnly *bool
|
||
RequirePrivacySet *bool
|
||
MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig
|
||
ModelsListConfig *GroupModelsListConfig
|
||
// RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。
|
||
RPMLimit *int
|
||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||
CopyAccountsFromGroupIDs []int64
|
||
}
|
||
|
||
type CreateAccountInput struct {
|
||
Name string
|
||
Notes *string
|
||
Platform string
|
||
Type string
|
||
Credentials map[string]any
|
||
Extra map[string]any
|
||
ProxyID *int64
|
||
Concurrency int
|
||
Priority int
|
||
RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
|
||
LoadFactor *int
|
||
GroupIDs []int64
|
||
ExpiresAt *int64
|
||
AutoPauseOnExpired *bool
|
||
// SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty.
|
||
SkipDefaultGroupBind bool
|
||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||
// This should only be set when the caller has explicitly confirmed the risk.
|
||
SkipMixedChannelCheck bool
|
||
}
|
||
|
||
// ShadowOptions is the input for CreateShadow.
|
||
// The shadow holds no credentials — the scheduler transparently delegates to the parent account's tokens.
|
||
type ShadowOptions struct {
|
||
Name string
|
||
Priority int
|
||
Concurrency int
|
||
GroupIDs []int64
|
||
}
|
||
|
||
type UpdateAccountInput struct {
|
||
Name string
|
||
Notes *string
|
||
Type string // Account type: oauth, setup-token, apikey
|
||
Credentials map[string]any
|
||
Extra map[string]any
|
||
ProxyID *int64
|
||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||
Priority *int // 使用指针区分"未提供"和"设置为0"
|
||
RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
|
||
LoadFactor *int
|
||
Status string
|
||
GroupIDs *[]int64
|
||
ExpiresAt *int64
|
||
AutoPauseOnExpired *bool
|
||
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
|
||
}
|
||
|
||
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
||
type BulkUpdateAccountsInput struct {
|
||
AccountIDs []int64
|
||
Filters *BulkUpdateAccountFilters
|
||
Name string
|
||
ProxyID *int64
|
||
Concurrency *int
|
||
Priority *int
|
||
RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
|
||
LoadFactor *int
|
||
Status string
|
||
Schedulable *bool
|
||
GroupIDs *[]int64
|
||
Credentials map[string]any
|
||
Extra map[string]any
|
||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||
// This should only be set when the caller has explicitly confirmed the risk.
|
||
SkipMixedChannelCheck bool
|
||
}
|
||
|
||
type BulkUpdateAccountFilters struct {
|
||
Platform string
|
||
Type string
|
||
Status string
|
||
Group string
|
||
Search string
|
||
PrivacyMode string
|
||
}
|
||
|
||
// BulkUpdateAccountResult captures the result for a single account update.
|
||
type BulkUpdateAccountResult struct {
|
||
AccountID int64 `json:"account_id"`
|
||
Success bool `json:"success"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// AdminUpdateAPIKeyGroupIDResult is the result of AdminUpdateAPIKeyGroupID.
|
||
type AdminUpdateAPIKeyGroupIDResult struct {
|
||
APIKey *APIKey
|
||
AutoGrantedGroupAccess bool // true if a new exclusive group permission was auto-added
|
||
GrantedGroupID *int64 // the group ID that was auto-granted
|
||
GrantedGroupName string // the group name that was auto-granted
|
||
}
|
||
|
||
// ReplaceUserGroupResult 分组替换操作的结果
|
||
type ReplaceUserGroupResult struct {
|
||
MigratedKeys int64 // 迁移的 Key 数量
|
||
}
|
||
|
||
// UserRPMStatus describes a user's current per-minute RPM usage.
|
||
type UserRPMStatus struct {
|
||
UserRPMUsed int `json:"user_rpm_used"`
|
||
UserRPMLimit int `json:"user_rpm_limit"`
|
||
PerGroup []UserGroupRPMStatus `json:"per_group"`
|
||
}
|
||
|
||
// UserGroupRPMStatus describes current per-minute RPM usage for one user/group pair.
|
||
type UserGroupRPMStatus struct {
|
||
GroupID int64 `json:"group_id"`
|
||
GroupName string `json:"group_name"`
|
||
Used int `json:"used"`
|
||
Limit int `json:"limit"`
|
||
Source string `json:"source"` // "group" | "override"
|
||
}
|
||
|
||
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
|
||
type BulkUpdateAccountsResult struct {
|
||
Success int `json:"success"`
|
||
Failed int `json:"failed"`
|
||
SuccessIDs []int64 `json:"success_ids"`
|
||
FailedIDs []int64 `json:"failed_ids"`
|
||
Results []BulkUpdateAccountResult `json:"results"`
|
||
}
|
||
|
||
type CreateProxyInput struct {
|
||
Name string
|
||
Protocol string
|
||
Host string
|
||
Port int
|
||
Username string
|
||
Password string
|
||
ExpiresAt *time.Time
|
||
FallbackMode string
|
||
BackupProxyID *int64
|
||
ExpiryWarnDays int
|
||
}
|
||
|
||
type UpdateProxyInput struct {
|
||
Name string
|
||
Protocol string
|
||
Host string
|
||
Port int
|
||
Username string
|
||
Password string
|
||
Status string
|
||
ExpiresAt *time.Time
|
||
FallbackMode string
|
||
BackupProxyID *int64
|
||
ExpiryWarnDays int
|
||
}
|
||
|
||
type GenerateRedeemCodesInput struct {
|
||
Count int
|
||
Type string
|
||
Value float64
|
||
GroupID *int64 // 订阅类型专用:关联的分组ID
|
||
ValidityDays int // 订阅类型专用:有效天数
|
||
ExpiresAt *time.Time
|
||
}
|
||
|
||
type ProxyBatchDeleteResult struct {
|
||
DeletedIDs []int64 `json:"deleted_ids"`
|
||
Skipped []ProxyBatchDeleteSkipped `json:"skipped"`
|
||
}
|
||
|
||
type ProxyBatchDeleteSkipped struct {
|
||
ID int64 `json:"id"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
// ProxyTestResult represents the result of testing a proxy
|
||
type ProxyTestResult struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||
IPAddress string `json:"ip_address,omitempty"`
|
||
City string `json:"city,omitempty"`
|
||
Region string `json:"region,omitempty"`
|
||
Country string `json:"country,omitempty"`
|
||
CountryCode string `json:"country_code,omitempty"`
|
||
}
|
||
|
||
type ProxyQualityCheckResult struct {
|
||
ProxyID int64 `json:"proxy_id"`
|
||
Score int `json:"score"`
|
||
Grade string `json:"grade"`
|
||
Summary string `json:"summary"`
|
||
ExitIP string `json:"exit_ip,omitempty"`
|
||
Country string `json:"country,omitempty"`
|
||
CountryCode string `json:"country_code,omitempty"`
|
||
BaseLatencyMs int64 `json:"base_latency_ms,omitempty"`
|
||
PassedCount int `json:"passed_count"`
|
||
WarnCount int `json:"warn_count"`
|
||
FailedCount int `json:"failed_count"`
|
||
ChallengeCount int `json:"challenge_count"`
|
||
CheckedAt int64 `json:"checked_at"`
|
||
Items []ProxyQualityCheckItem `json:"items"`
|
||
}
|
||
|
||
type ProxyQualityCheckItem struct {
|
||
Target string `json:"target"`
|
||
Status string `json:"status"` // pass/warn/fail/challenge
|
||
HTTPStatus int `json:"http_status,omitempty"`
|
||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
CFRay string `json:"cf_ray,omitempty"`
|
||
}
|
||
|
||
// ProxyExitInfo represents proxy exit information from ip-api.com
|
||
type ProxyExitInfo struct {
|
||
IP string
|
||
City string
|
||
Region string
|
||
Country string
|
||
CountryCode string
|
||
}
|
||
|
||
// ProxyExitInfoProber tests proxy connectivity and retrieves exit information
|
||
type ProxyExitInfoProber interface {
|
||
ProbeProxy(ctx context.Context, proxyURL string) (*ProxyExitInfo, int64, error)
|
||
}
|
||
|
||
type groupExistenceBatchReader interface {
|
||
ExistsByIDs(ctx context.Context, ids []int64) (map[int64]bool, error)
|
||
}
|
||
|
||
type proxyQualityTarget struct {
|
||
Target string
|
||
URL string
|
||
Method string
|
||
AllowedStatuses map[int]struct{}
|
||
}
|
||
|
||
var proxyQualityTargets = []proxyQualityTarget{
|
||
{
|
||
Target: "openai",
|
||
URL: "https://api.openai.com/v1/models",
|
||
Method: http.MethodGet,
|
||
AllowedStatuses: map[int]struct{}{
|
||
http.StatusUnauthorized: {},
|
||
},
|
||
},
|
||
{
|
||
Target: "anthropic",
|
||
URL: "https://api.anthropic.com/v1/messages",
|
||
Method: http.MethodGet,
|
||
AllowedStatuses: map[int]struct{}{
|
||
http.StatusUnauthorized: {},
|
||
http.StatusMethodNotAllowed: {},
|
||
http.StatusNotFound: {},
|
||
http.StatusBadRequest: {},
|
||
},
|
||
},
|
||
{
|
||
Target: "gemini",
|
||
URL: "https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta",
|
||
Method: http.MethodGet,
|
||
AllowedStatuses: map[int]struct{}{
|
||
http.StatusOK: {},
|
||
},
|
||
},
|
||
}
|
||
|
||
const (
|
||
proxyQualityRequestTimeout = 15 * time.Second
|
||
proxyQualityResponseHeaderTimeout = 10 * time.Second
|
||
proxyQualityMaxBodyBytes = int64(8 * 1024)
|
||
proxyQualityClientUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_STATUS_UNAVAILABLE", "RPM cache not available")
|
||
|
||
// adminServiceImpl implements AdminService
|
||
type adminServiceImpl struct {
|
||
userRepo UserRepository
|
||
groupRepo GroupRepository
|
||
accountRepo AccountRepository
|
||
accountDuplicateRepo AccountDuplicateRepository
|
||
proxyRepo ProxyRepository
|
||
apiKeyRepo APIKeyRepository
|
||
redeemCodeRepo RedeemCodeRepository
|
||
userGroupRateRepo UserGroupRateRepository
|
||
userRPMCache UserRPMCache
|
||
billingCacheService *BillingCacheService
|
||
proxyProber ProxyExitInfoProber
|
||
proxyLatencyCache ProxyLatencyCache
|
||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||
entClient *dbent.Client // 用于开启数据库事务
|
||
settingService *SettingService
|
||
defaultSubAssigner DefaultSubscriptionAssigner
|
||
userSubRepo UserSubscriptionRepository
|
||
privacyClientFactory PrivacyClientFactory
|
||
runtimeBlocker AccountRuntimeBlocker
|
||
}
|
||
|
||
type userGroupRateBatchReader interface {
|
||
GetByUserIDs(ctx context.Context, userIDs []int64) (map[int64]map[int64]float64, error)
|
||
}
|
||
|
||
// NewAdminService creates a new AdminService
|
||
func NewAdminService(
|
||
userRepo UserRepository,
|
||
groupRepo GroupRepository,
|
||
accountRepo AdminAccountRepository,
|
||
proxyRepo ProxyRepository,
|
||
apiKeyRepo APIKeyRepository,
|
||
redeemCodeRepo RedeemCodeRepository,
|
||
userGroupRateRepo UserGroupRateRepository,
|
||
userRPMCache UserRPMCache,
|
||
billingCacheService *BillingCacheService,
|
||
proxyProber ProxyExitInfoProber,
|
||
proxyLatencyCache ProxyLatencyCache,
|
||
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
||
entClient *dbent.Client,
|
||
settingService *SettingService,
|
||
defaultSubAssigner DefaultSubscriptionAssigner,
|
||
userSubRepo UserSubscriptionRepository,
|
||
privacyClientFactory PrivacyClientFactory,
|
||
runtimeBlocker AccountRuntimeBlocker,
|
||
) AdminService {
|
||
return &adminServiceImpl{
|
||
userRepo: userRepo,
|
||
groupRepo: groupRepo,
|
||
accountRepo: accountRepo,
|
||
accountDuplicateRepo: accountRepo,
|
||
proxyRepo: proxyRepo,
|
||
apiKeyRepo: apiKeyRepo,
|
||
redeemCodeRepo: redeemCodeRepo,
|
||
userGroupRateRepo: userGroupRateRepo,
|
||
userRPMCache: userRPMCache,
|
||
billingCacheService: billingCacheService,
|
||
proxyProber: proxyProber,
|
||
proxyLatencyCache: proxyLatencyCache,
|
||
authCacheInvalidator: authCacheInvalidator,
|
||
entClient: entClient,
|
||
settingService: settingService,
|
||
defaultSubAssigner: defaultSubAssigner,
|
||
userSubRepo: userSubRepo,
|
||
privacyClientFactory: privacyClientFactory,
|
||
runtimeBlocker: runtimeBlocker,
|
||
}
|
||
}
|