2025-12-18 13:50:39 +08:00
package service
import (
"context"
"errors"
"fmt"
2026-02-19 21:18:35 +08:00
"io"
"net/http"
2026-01-03 06:34:00 -08:00
"strings"
2025-12-18 13:50:39 +08:00
"time"
2026-02-28 17:33:30 +08:00
dbent "github.com/Wei-Shaw/sub2api/ent"
2026-02-28 00:07:44 +08:00
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
2026-02-19 21:18:35 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
2026-02-12 19:01:09 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
2025-12-24 21:07:21 +08:00
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
2026-02-19 21:18:35 +08:00
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
2025-12-18 13:50:39 +08:00
)
// AdminService interface defines admin management operations
type AdminService interface {
// User management
2026-01-01 18:59:38 +08:00
ListUsers ( ctx context . Context , page , pageSize int , filters UserListFilters ) ( [ ] User , int64 , error )
2025-12-26 15:40:24 +08:00
GetUser ( 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 )
2025-12-18 13:50:39 +08:00
DeleteUser ( ctx context . Context , id int64 ) error
2025-12-26 15:40:24 +08:00
UpdateUserBalance ( ctx context . Context , userID int64 , balance float64 , operation string , notes string ) ( * User , error )
2026-01-04 19:27:53 +08:00
GetUserAPIKeys ( ctx context . Context , userID int64 , page , pageSize int ) ( [ ] APIKey , int64 , error )
2025-12-20 16:19:40 +08:00
GetUserUsageStats ( ctx context . Context , userID int64 , period string ) ( any , error )
2026-02-03 00:16:10 +08:00
// 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 )
2025-12-18 13:50:39 +08:00
// Group management
2026-01-09 18:58:06 +08:00
ListGroups ( ctx context . Context , page , pageSize int , platform , status , search string , isExclusive * bool ) ( [ ] Group , int64 , error )
2025-12-26 15:40:24 +08:00
GetAllGroups ( ctx context . Context ) ( [ ] Group , error )
GetAllGroupsByPlatform ( ctx context . Context , platform string ) ( [ ] Group , error )
GetGroup ( ctx context . Context , id int64 ) ( * Group , error )
CreateGroup ( ctx context . Context , input * CreateGroupInput ) ( * Group , error )
UpdateGroup ( ctx context . Context , id int64 , input * UpdateGroupInput ) ( * Group , error )
2025-12-18 13:50:39 +08:00
DeleteGroup ( ctx context . Context , id int64 ) error
2026-01-04 19:27:53 +08:00
GetGroupAPIKeys ( ctx context . Context , groupID int64 , page , pageSize int ) ( [ ] APIKey , int64 , error )
2026-03-12 23:37:36 +08:00
GetGroupRateMultipliers ( ctx context . Context , groupID int64 ) ( [ ] UserGroupRateEntry , error )
ClearGroupRateMultipliers ( ctx context . Context , groupID int64 ) error
BatchSetGroupRateMultipliers ( ctx context . Context , groupID int64 , entries [ ] GroupRateMultiplierInput ) error
2026-02-08 16:53:45 +08:00
UpdateGroupSortOrders ( ctx context . Context , updates [ ] GroupSortOrderUpdate ) error
2025-12-18 13:50:39 +08:00
2026-02-28 00:07:44 +08:00
// API Key management (admin)
2026-02-28 17:33:30 +08:00
AdminUpdateAPIKeyGroupID ( ctx context . Context , keyID int64 , groupID * int64 ) ( * AdminUpdateAPIKeyGroupIDResult , error )
2026-02-28 00:07:44 +08:00
2025-12-18 13:50:39 +08:00
// Account management
2026-02-12 03:47:06 +08:00
ListAccounts ( ctx context . Context , page , pageSize int , platform , accountType , status , search string , groupID int64 ) ( [ ] Account , int64 , error )
2025-12-26 15:40:24 +08:00
GetAccount ( ctx context . Context , id int64 ) ( * Account , error )
2026-01-01 15:07:16 +08:00
GetAccountsByIDs ( ctx context . Context , ids [ ] int64 ) ( [ ] * Account , error )
2025-12-26 15:40:24 +08:00
CreateAccount ( ctx context . Context , input * CreateAccountInput ) ( * Account , error )
UpdateAccount ( ctx context . Context , id int64 , input * UpdateAccountInput ) ( * Account , error )
2025-12-18 13:50:39 +08:00
DeleteAccount ( ctx context . Context , id int64 ) error
2025-12-26 15:40:24 +08:00
RefreshAccountCredentials ( ctx context . Context , id int64 ) ( * Account , error )
ClearAccountError ( ctx context . Context , id int64 ) ( * Account , error )
2026-01-16 12:13:54 +08:00
SetAccountError ( ctx context . Context , id int64 , errorMsg string ) error
2026-03-12 19:45:13 +08:00
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode, 未设置则尝试关闭训练数据共享并持久化。
EnsureOpenAIPrivacy ( ctx context . Context , account * Account ) string
2025-12-26 15:40:24 +08:00
SetAccountSchedulable ( ctx context . Context , id int64 , schedulable bool ) ( * Account , error )
2025-12-24 17:16:19 -08:00
BulkUpdateAccounts ( ctx context . Context , input * BulkUpdateAccountsInput ) ( * BulkUpdateAccountsResult , error )
2026-02-24 17:11:14 +08:00
CheckMixedChannelRisk ( ctx context . Context , currentAccountID int64 , currentAccountPlatform string , groupIDs [ ] int64 ) error
2025-12-18 13:50:39 +08:00
// Proxy management
2025-12-26 15:40:24 +08:00
ListProxies ( ctx context . Context , page , pageSize int , protocol , status , search string ) ( [ ] Proxy , int64 , error )
2026-01-08 21:20:12 +08:00
ListProxiesWithAccountCount ( ctx context . Context , page , pageSize int , protocol , status , search string ) ( [ ] ProxyWithAccountCount , int64 , error )
2025-12-26 15:40:24 +08:00
GetAllProxies ( ctx context . Context ) ( [ ] Proxy , error )
GetAllProxiesWithAccountCount ( ctx context . Context ) ( [ ] ProxyWithAccountCount , error )
GetProxy ( ctx context . Context , id int64 ) ( * Proxy , error )
2026-02-05 18:40:49 +08:00
GetProxiesByIDs ( ctx context . Context , ids [ ] int64 ) ( [ ] Proxy , error )
2025-12-26 15:40:24 +08:00
CreateProxy ( ctx context . Context , input * CreateProxyInput ) ( * Proxy , error )
UpdateProxy ( ctx context . Context , id int64 , input * UpdateProxyInput ) ( * Proxy , error )
2025-12-18 13:50:39 +08:00
DeleteProxy ( ctx context . Context , id int64 ) error
2026-01-14 19:45:29 +08:00
BatchDeleteProxies ( ctx context . Context , ids [ ] int64 ) ( * ProxyBatchDeleteResult , error )
GetProxyAccounts ( ctx context . Context , proxyID int64 ) ( [ ] ProxyAccountSummary , error )
2025-12-18 13:50:39 +08:00
CheckProxyExists ( ctx context . Context , host string , port int , username , password string ) ( bool , error )
TestProxy ( ctx context . Context , id int64 ) ( * ProxyTestResult , error )
2026-02-19 21:18:35 +08:00
CheckProxyQuality ( ctx context . Context , id int64 ) ( * ProxyQualityCheckResult , error )
2025-12-18 13:50:39 +08:00
// Redeem code management
2025-12-26 15:40:24 +08:00
ListRedeemCodes ( ctx context . Context , page , pageSize int , codeType , status , search string ) ( [ ] RedeemCode , int64 , error )
GetRedeemCode ( ctx context . Context , id int64 ) ( * RedeemCode , error )
GenerateRedeemCodes ( ctx context . Context , input * GenerateRedeemCodesInput ) ( [ ] RedeemCode , error )
2025-12-18 13:50:39 +08:00
DeleteRedeemCode ( ctx context . Context , id int64 ) error
BatchDeleteRedeemCodes ( ctx context . Context , ids [ ] int64 ) ( int64 , error )
2025-12-26 15:40:24 +08:00
ExpireRedeemCode ( ctx context . Context , id int64 ) ( * RedeemCode , error )
2026-03-05 20:54:37 +08:00
ResetAccountQuota ( ctx context . Context , id int64 ) error
2025-12-18 13:50:39 +08:00
}
2026-01-04 19:27:53 +08:00
// CreateUserInput represents input for creating a new user via admin operations.
2025-12-18 13:50:39 +08:00
type CreateUserInput struct {
2026-02-28 15:01:20 +08:00
Email string
Password string
Username string
Notes string
Balance float64
Concurrency int
AllowedGroups [ ] int64
SoraStorageQuotaBytes int64
2025-12-18 13:50:39 +08:00
}
type UpdateUserInput struct {
Email string
Password string
2025-12-23 11:26:22 +08:00
Username * string
Notes * string
2025-12-18 13:50:39 +08:00
Balance * float64 // 使用指针区分"未提供"和"设置为0"
Concurrency * int // 使用指针区分"未提供"和"设置为0"
Status string
AllowedGroups * [ ] int64 // 使用指针区分"未提供"和"设置为空数组"
2026-02-05 16:00:34 +08:00
// GroupRates 用户专属分组倍率配置
// map[groupID]*rate, nil 表示删除该分组的专属倍率
2026-02-28 15:01:20 +08:00
GroupRates map [ int64 ] * float64
SoraStorageQuotaBytes * int64
2025-12-18 13:50:39 +08:00
}
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)
2026-01-05 17:07:29 +08:00
// 图片生成计费配置(仅 antigravity 平台使用)
2026-01-31 20:22:22 +08:00
ImagePrice1K * float64
ImagePrice2K * float64
ImagePrice4K * float64
// Sora 按次计费配置
SoraImagePrice360 * float64
SoraImagePrice540 * float64
SoraVideoPricePerRequest * float64
SoraVideoPricePerRequestHD * float64
ClaudeCodeOnly bool // 仅允许 Claude Code 客户端
FallbackGroupID * int64 // 降级分组 ID
2026-01-23 22:24:46 +08:00
// 无效请求兜底分组 ID( 仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest * int64
2026-01-16 17:26:05 +08:00
// 模型路由配置(仅 anthropic 平台使用)
2026-03-16 05:07:20 +08:00
ModelRouting map [ string ] [ ] int64
ModelRoutingEnabled bool // 是否启用模型路由
MCPXMLInject * bool
2026-02-02 22:20:08 +08:00
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes [ ] string
2026-02-28 15:01:20 +08:00
// Sora 存储配额
SoraStorageQuotaBytes int64
2026-03-07 17:02:19 +08:00
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch bool
DefaultMappedModel string
2026-02-02 16:46:25 +08:00
// 从指定分组复制账号(创建分组后在同一事务内绑定)
CopyAccountsFromGroupIDs [ ] int64
2025-12-18 13:50:39 +08:00
}
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)
2026-01-05 17:07:29 +08:00
// 图片生成计费配置(仅 antigravity 平台使用)
2026-01-31 20:22:22 +08:00
ImagePrice1K * float64
ImagePrice2K * float64
ImagePrice4K * float64
// Sora 按次计费配置
SoraImagePrice360 * float64
SoraImagePrice540 * float64
SoraVideoPricePerRequest * float64
SoraVideoPricePerRequestHD * float64
ClaudeCodeOnly * bool // 仅允许 Claude Code 客户端
FallbackGroupID * int64 // 降级分组 ID
2026-01-23 22:24:46 +08:00
// 无效请求兜底分组 ID( 仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest * int64
2026-01-16 17:26:05 +08:00
// 模型路由配置(仅 anthropic 平台使用)
2026-03-16 05:07:20 +08:00
ModelRouting map [ string ] [ ] int64
ModelRoutingEnabled * bool // 是否启用模型路由
MCPXMLInject * bool
2026-02-02 22:20:08 +08:00
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes * [ ] string
2026-02-28 15:01:20 +08:00
// Sora 存储配额
SoraStorageQuotaBytes * int64
2026-03-07 17:02:19 +08:00
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch * bool
DefaultMappedModel * string
2026-02-02 16:46:25 +08:00
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs [ ] int64
2025-12-18 13:50:39 +08:00
}
type CreateAccountInput struct {
2026-01-07 16:59:35 +08:00
Name string
Notes * string
Platform string
Type string
Credentials map [ string ] any
Extra map [ string ] any
ProxyID * int64
Concurrency int
Priority int
2026-01-14 16:12:08 +08:00
RateMultiplier * float64 // 账号计费倍率(>=0, 允许 0)
2026-03-06 05:07:10 +08:00
LoadFactor * int
2026-01-07 16:59:35 +08:00
GroupIDs [ ] int64
ExpiresAt * int64
AutoPauseOnExpired * bool
2026-02-05 17:46:08 +08:00
// SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty.
SkipDefaultGroupBind bool
2026-01-03 06:34:00 -08:00
// 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
2025-12-18 13:50:39 +08:00
}
type UpdateAccountInput struct {
2026-01-03 06:34:00 -08:00
Name string
2026-01-05 14:07:33 +08:00
Notes * string
2026-01-03 06:34:00 -08:00
Type string // Account type: oauth, setup-token, apikey
Credentials map [ string ] any
Extra map [ string ] any
ProxyID * int64
2026-01-14 16:12:08 +08:00
Concurrency * int // 使用指针区分"未提供"和"设置为0"
Priority * int // 使用指针区分"未提供"和"设置为0"
RateMultiplier * float64 // 账号计费倍率(>=0, 允许 0)
2026-03-06 05:07:10 +08:00
LoadFactor * int
2026-01-03 06:34:00 -08:00
Status string
GroupIDs * [ ] int64
2026-01-07 16:59:35 +08:00
ExpiresAt * int64
AutoPauseOnExpired * bool
2026-01-03 06:34:00 -08:00
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
2025-12-18 13:50:39 +08:00
}
2025-12-24 17:16:19 -08:00
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
type BulkUpdateAccountsInput struct {
2026-01-14 16:12:08 +08:00
AccountIDs [ ] int64
Name string
ProxyID * int64
Concurrency * int
Priority * int
RateMultiplier * float64 // 账号计费倍率(>=0, 允许 0)
2026-03-06 05:07:10 +08:00
LoadFactor * int
2026-01-14 16:12:08 +08:00
Status string
Schedulable * bool
GroupIDs * [ ] int64
Credentials map [ string ] any
Extra map [ string ] any
2026-01-03 06:34:00 -08:00
// 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
2025-12-24 17:16:19 -08:00
}
// 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" `
}
2026-02-28 17:33:30 +08:00
// 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
}
2025-12-24 17:16:19 -08:00
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
type BulkUpdateAccountsResult struct {
2026-01-11 20:22:17 +08:00
Success int ` json:"success" `
Failed int ` json:"failed" `
SuccessIDs [ ] int64 ` json:"success_ids" `
FailedIDs [ ] int64 ` json:"failed_ids" `
Results [ ] BulkUpdateAccountResult ` json:"results" `
2025-12-24 17:16:19 -08:00
}
2025-12-18 13:50:39 +08:00
type CreateProxyInput struct {
Name string
Protocol string
Host string
Port int
Username string
Password string
}
type UpdateProxyInput struct {
Name string
Protocol string
Host string
Port int
Username string
Password string
Status string
}
type GenerateRedeemCodesInput struct {
Count int
Type string
Value float64
GroupID * int64 // 订阅类型专用: 关联的分组ID
ValidityDays int // 订阅类型专用:有效天数
}
2026-01-14 19:45:29 +08:00
type ProxyBatchDeleteResult struct {
DeletedIDs [ ] int64 ` json:"deleted_ids" `
Skipped [ ] ProxyBatchDeleteSkipped ` json:"skipped" `
}
type ProxyBatchDeleteSkipped struct {
ID int64 ` json:"id" `
Reason string ` json:"reason" `
}
2025-12-18 13:50:39 +08:00
// ProxyTestResult represents the result of testing a proxy
type ProxyTestResult struct {
2026-01-15 15:15:20 +08:00
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" `
2025-12-18 13:50:39 +08:00
}
2026-02-19 21:18:35 +08:00
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" `
}
2026-01-15 15:15:20 +08:00
// ProxyExitInfo represents proxy exit information from ip-api.com
2025-12-20 11:56:11 +08:00
type ProxyExitInfo struct {
2026-01-15 15:15:20 +08:00
IP string
City string
Region string
Country string
CountryCode string
2025-12-20 11:56:11 +08:00
}
// ProxyExitInfoProber tests proxy connectivity and retrieves exit information
type ProxyExitInfoProber interface {
ProbeProxy ( ctx context . Context , proxyURL string ) ( * ProxyExitInfo , int64 , error )
}
2026-03-16 04:31:22 +08:00
type groupExistenceBatchReader interface {
ExistsByIDs ( ctx context . Context , ids [ ] int64 ) ( map [ int64 ] bool , error )
}
2026-02-19 21:18:35 +08:00
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 : { } ,
} ,
} ,
{
Target : "sora" ,
URL : "https://sora.chatgpt.com/backend/me" ,
Method : http . MethodGet ,
AllowedStatuses : map [ int ] struct { } {
http . StatusUnauthorized : { } ,
} ,
} ,
}
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"
)
2025-12-18 13:50:39 +08:00
// adminServiceImpl implements AdminService
type adminServiceImpl struct {
2026-01-10 22:23:51 +08:00
userRepo UserRepository
groupRepo GroupRepository
accountRepo AccountRepository
2026-01-30 14:08:04 +08:00
soraAccountRepo SoraAccountRepository // Sora 账号扩展表仓储
2026-01-10 22:23:51 +08:00
proxyRepo ProxyRepository
apiKeyRepo APIKeyRepository
redeemCodeRepo RedeemCodeRepository
2026-02-05 16:00:34 +08:00
userGroupRateRepo UserGroupRateRepository
2026-01-10 22:23:51 +08:00
billingCacheService * BillingCacheService
proxyProber ProxyExitInfoProber
2026-01-14 19:45:29 +08:00
proxyLatencyCache ProxyLatencyCache
2026-01-10 22:23:51 +08:00
authCacheInvalidator APIKeyAuthCacheInvalidator
2026-02-28 17:33:30 +08:00
entClient * dbent . Client // 用于开启数据库事务
2026-03-02 03:41:50 +08:00
settingService * SettingService
defaultSubAssigner DefaultSubscriptionAssigner
2026-03-10 00:51:43 +08:00
userSubRepo UserSubscriptionRepository
2026-03-12 19:45:13 +08:00
privacyClientFactory PrivacyClientFactory
2025-12-18 13:50:39 +08:00
}
2026-02-28 15:01:20 +08:00
type userGroupRateBatchReader interface {
GetByUserIDs ( ctx context . Context , userIDs [ ] int64 ) ( map [ int64 ] map [ int64 ] float64 , error )
}
2025-12-18 13:50:39 +08:00
// NewAdminService creates a new AdminService
2025-12-19 21:26:19 +08:00
func NewAdminService (
2025-12-25 17:15:01 +08:00
userRepo UserRepository ,
groupRepo GroupRepository ,
accountRepo AccountRepository ,
2026-01-30 14:08:04 +08:00
soraAccountRepo SoraAccountRepository ,
2025-12-25 17:15:01 +08:00
proxyRepo ProxyRepository ,
2026-01-04 19:27:53 +08:00
apiKeyRepo APIKeyRepository ,
2025-12-25 17:15:01 +08:00
redeemCodeRepo RedeemCodeRepository ,
2026-02-05 16:00:34 +08:00
userGroupRateRepo UserGroupRateRepository ,
2025-12-19 21:26:19 +08:00
billingCacheService * BillingCacheService ,
2025-12-20 11:56:11 +08:00
proxyProber ProxyExitInfoProber ,
2026-01-14 19:45:29 +08:00
proxyLatencyCache ProxyLatencyCache ,
2026-01-10 22:23:51 +08:00
authCacheInvalidator APIKeyAuthCacheInvalidator ,
2026-02-28 17:33:30 +08:00
entClient * dbent . Client ,
2026-03-02 03:41:50 +08:00
settingService * SettingService ,
defaultSubAssigner DefaultSubscriptionAssigner ,
2026-03-10 00:51:43 +08:00
userSubRepo UserSubscriptionRepository ,
2026-03-12 19:45:13 +08:00
privacyClientFactory PrivacyClientFactory ,
2025-12-19 21:26:19 +08:00
) AdminService {
2025-12-18 13:50:39 +08:00
return & adminServiceImpl {
2026-01-10 22:23:51 +08:00
userRepo : userRepo ,
groupRepo : groupRepo ,
accountRepo : accountRepo ,
2026-01-30 14:08:04 +08:00
soraAccountRepo : soraAccountRepo ,
2026-01-10 22:23:51 +08:00
proxyRepo : proxyRepo ,
apiKeyRepo : apiKeyRepo ,
redeemCodeRepo : redeemCodeRepo ,
2026-02-05 16:00:34 +08:00
userGroupRateRepo : userGroupRateRepo ,
2026-01-10 22:23:51 +08:00
billingCacheService : billingCacheService ,
proxyProber : proxyProber ,
2026-01-14 19:45:29 +08:00
proxyLatencyCache : proxyLatencyCache ,
2026-01-10 22:23:51 +08:00
authCacheInvalidator : authCacheInvalidator ,
2026-02-28 17:33:30 +08:00
entClient : entClient ,
2026-03-02 03:41:50 +08:00
settingService : settingService ,
defaultSubAssigner : defaultSubAssigner ,
2026-03-10 00:51:43 +08:00
userSubRepo : userSubRepo ,
2026-03-12 19:45:13 +08:00
privacyClientFactory : privacyClientFactory ,
2025-12-18 13:50:39 +08:00
}
}
// User management implementations
2026-01-01 18:59:38 +08:00
func ( s * adminServiceImpl ) ListUsers ( ctx context . Context , page , pageSize int , filters UserListFilters ) ( [ ] User , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2026-01-01 18:59:38 +08:00
users , result , err := s . userRepo . ListWithFilters ( ctx , params , filters )
2025-12-18 13:50:39 +08:00
if err != nil {
return nil , 0 , err
}
2026-02-05 16:00:34 +08:00
// 批量加载用户专属分组倍率
if s . userGroupRateRepo != nil && len ( users ) > 0 {
2026-02-28 15:01:20 +08:00
if batchRepo , ok := s . userGroupRateRepo . ( userGroupRateBatchReader ) ; ok {
userIDs := make ( [ ] int64 , 0 , len ( users ) )
for i := range users {
userIDs = append ( userIDs , users [ i ] . ID )
}
ratesByUser , err := batchRepo . GetByUserIDs ( ctx , userIDs )
2026-02-05 16:00:34 +08:00
if err != nil {
2026-02-28 15:01:20 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to load user group rates in batch: err=%v" , err )
s . loadUserGroupRatesOneByOne ( ctx , users )
} else {
for i := range users {
if rates , ok := ratesByUser [ users [ i ] . ID ] ; ok {
users [ i ] . GroupRates = rates
}
}
2026-02-05 16:00:34 +08:00
}
2026-02-28 15:01:20 +08:00
} else {
s . loadUserGroupRatesOneByOne ( ctx , users )
2026-02-05 16:00:34 +08:00
}
}
2025-12-18 13:50:39 +08:00
return users , result . Total , nil
}
2026-02-28 15:01:20 +08:00
func ( s * adminServiceImpl ) loadUserGroupRatesOneByOne ( ctx context . Context , users [ ] User ) {
if s . userGroupRateRepo == nil {
return
}
for i := range users {
rates , err := s . userGroupRateRepo . GetByUserID ( ctx , users [ i ] . ID )
if err != nil {
logger . LegacyPrintf ( "service.admin" , "failed to load user group rates: user_id=%d err=%v" , users [ i ] . ID , err )
continue
}
users [ i ] . GroupRates = rates
}
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetUser ( ctx context . Context , id int64 ) ( * User , error ) {
2026-02-05 16:00:34 +08:00
user , err := s . userRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
// 加载用户专属分组倍率
if s . userGroupRateRepo != nil {
rates , err := s . userGroupRateRepo . GetByUserID ( ctx , id )
if err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to load user group rates: user_id=%d err=%v" , id , err )
2026-02-05 16:00:34 +08:00
} else {
user . GroupRates = rates
}
}
return user , nil
2025-12-18 13:50:39 +08:00
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) CreateUser ( ctx context . Context , input * CreateUserInput ) ( * User , error ) {
user := & User {
2026-02-28 15:01:20 +08:00
Email : input . Email ,
Username : input . Username ,
Notes : input . Notes ,
Role : RoleUser , // Always create as regular user, never admin
Balance : input . Balance ,
Concurrency : input . Concurrency ,
Status : StatusActive ,
AllowedGroups : input . AllowedGroups ,
SoraStorageQuotaBytes : input . SoraStorageQuotaBytes ,
2025-12-18 13:50:39 +08:00
}
if err := user . SetPassword ( input . Password ) ; err != nil {
return nil , err
}
if err := s . userRepo . Create ( ctx , user ) ; err != nil {
return nil , err
}
2026-03-02 03:41:50 +08:00
s . assignDefaultSubscriptions ( ctx , user . ID )
2025-12-18 13:50:39 +08:00
return user , nil
}
2026-03-02 03:41:50 +08:00
func ( s * adminServiceImpl ) assignDefaultSubscriptions ( ctx context . Context , userID int64 ) {
if s . settingService == nil || s . defaultSubAssigner == nil || userID <= 0 {
return
}
items := s . settingService . GetDefaultSubscriptions ( ctx )
for _ , item := range items {
if _ , _ , err := s . defaultSubAssigner . AssignOrExtendSubscription ( ctx , & AssignSubscriptionInput {
UserID : userID ,
GroupID : item . GroupID ,
ValidityDays : item . ValidityDays ,
Notes : "auto assigned by default user subscriptions setting" ,
} ) ; err != nil {
logger . LegacyPrintf ( "service.admin" , "failed to assign default subscription: user_id=%d group_id=%d err=%v" , userID , item . GroupID , err )
}
}
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) UpdateUser ( ctx context . Context , id int64 , input * UpdateUserInput ) ( * User , error ) {
2025-12-18 13:50:39 +08:00
user , err := s . userRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
// Protect admin users: cannot disable admin accounts
if user . Role == "admin" && input . Status == "disabled" {
return nil , errors . New ( "cannot disable admin user" )
}
oldConcurrency := user . Concurrency
2026-01-10 22:23:51 +08:00
oldStatus := user . Status
oldRole := user . Role
2025-12-18 13:50:39 +08:00
if input . Email != "" {
user . Email = input . Email
}
if input . Password != "" {
if err := user . SetPassword ( input . Password ) ; err != nil {
return nil , err
}
}
2025-12-23 11:26:22 +08:00
if input . Username != nil {
user . Username = * input . Username
}
if input . Notes != nil {
user . Notes = * input . Notes
}
2025-12-18 13:50:39 +08:00
if input . Status != "" {
user . Status = input . Status
}
if input . Concurrency != nil {
user . Concurrency = * input . Concurrency
}
if input . AllowedGroups != nil {
user . AllowedGroups = * input . AllowedGroups
}
2026-02-28 15:01:20 +08:00
if input . SoraStorageQuotaBytes != nil {
user . SoraStorageQuotaBytes = * input . SoraStorageQuotaBytes
}
2025-12-18 13:50:39 +08:00
if err := s . userRepo . Update ( ctx , user ) ; err != nil {
return nil , err
}
2026-02-05 16:00:34 +08:00
// 同步用户专属分组倍率
if input . GroupRates != nil && s . userGroupRateRepo != nil {
if err := s . userGroupRateRepo . SyncUserGroupRates ( ctx , user . ID , input . GroupRates ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to sync user group rates: user_id=%d err=%v" , user . ID , err )
2026-02-05 16:00:34 +08:00
}
}
2026-01-10 22:23:51 +08:00
if s . authCacheInvalidator != nil {
if user . Concurrency != oldConcurrency || user . Status != oldStatus || user . Role != oldRole {
s . authCacheInvalidator . InvalidateAuthCacheByUserID ( ctx , user . ID )
}
}
2025-12-18 13:50:39 +08:00
concurrencyDiff := user . Concurrency - oldConcurrency
if concurrencyDiff != 0 {
2025-12-26 15:40:24 +08:00
code , err := GenerateRedeemCode ( )
2025-12-20 15:29:52 +08:00
if err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to generate adjustment redeem code: %v" , err )
2025-12-20 15:29:52 +08:00
return user , nil
}
2025-12-26 15:40:24 +08:00
adjustmentRecord := & RedeemCode {
2025-12-20 15:29:52 +08:00
Code : code ,
2025-12-26 15:40:24 +08:00
Type : AdjustmentTypeAdminConcurrency ,
2025-12-18 13:50:39 +08:00
Value : float64 ( concurrencyDiff ) ,
2025-12-26 15:40:24 +08:00
Status : StatusUsed ,
2025-12-18 13:50:39 +08:00
UsedBy : & user . ID ,
}
now := time . Now ( )
adjustmentRecord . UsedAt = & now
if err := s . redeemCodeRepo . Create ( ctx , adjustmentRecord ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to create concurrency adjustment redeem code: %v" , err )
2025-12-18 13:50:39 +08:00
}
}
return user , nil
}
func ( s * adminServiceImpl ) DeleteUser ( ctx context . Context , id int64 ) error {
// Protect admin users: cannot delete admin accounts
user , err := s . userRepo . GetByID ( ctx , id )
if err != nil {
return err
}
if user . Role == "admin" {
return errors . New ( "cannot delete admin user" )
}
2025-12-29 16:57:50 +08:00
if err := s . userRepo . Delete ( ctx , id ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "delete user failed: user_id=%d err=%v" , id , err )
2025-12-29 16:57:50 +08:00
return err
}
2026-01-10 22:23:51 +08:00
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByUserID ( ctx , id )
}
2025-12-29 16:57:50 +08:00
return nil
2025-12-18 13:50:39 +08:00
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) UpdateUserBalance ( ctx context . Context , userID int64 , balance float64 , operation string , notes string ) ( * User , error ) {
2025-12-18 13:50:39 +08:00
user , err := s . userRepo . GetByID ( ctx , userID )
if err != nil {
return nil , err
}
2025-12-23 16:29:57 +08:00
oldBalance := user . Balance
2025-12-18 13:50:39 +08:00
switch operation {
case "set" :
user . Balance = balance
case "add" :
user . Balance += balance
case "subtract" :
user . Balance -= balance
}
2025-12-23 16:29:57 +08:00
if user . Balance < 0 {
return nil , fmt . Errorf ( "balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f" , oldBalance , user . Balance )
}
2025-12-18 13:50:39 +08:00
if err := s . userRepo . Update ( ctx , user ) ; err != nil {
return nil , err
}
2026-01-10 22:23:51 +08:00
balanceDiff := user . Balance - oldBalance
if s . authCacheInvalidator != nil && balanceDiff != 0 {
s . authCacheInvalidator . InvalidateAuthCacheByUserID ( ctx , userID )
}
2025-12-18 13:50:39 +08:00
if s . billingCacheService != nil {
go func ( ) {
cacheCtx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
2025-12-20 15:29:52 +08:00
if err := s . billingCacheService . InvalidateUserBalance ( cacheCtx , userID ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "invalidate user balance cache failed: user_id=%d err=%v" , userID , err )
2025-12-20 15:29:52 +08:00
}
2025-12-18 13:50:39 +08:00
} ( )
}
2025-12-23 16:29:57 +08:00
if balanceDiff != 0 {
2025-12-26 15:40:24 +08:00
code , err := GenerateRedeemCode ( )
2025-12-23 16:29:57 +08:00
if err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to generate adjustment redeem code: %v" , err )
2025-12-23 16:29:57 +08:00
return user , nil
}
2025-12-26 15:40:24 +08:00
adjustmentRecord := & RedeemCode {
2025-12-23 16:29:57 +08:00
Code : code ,
2025-12-26 15:40:24 +08:00
Type : AdjustmentTypeAdminBalance ,
2025-12-23 16:29:57 +08:00
Value : balanceDiff ,
2025-12-26 15:40:24 +08:00
Status : StatusUsed ,
2025-12-23 16:29:57 +08:00
UsedBy : & user . ID ,
Notes : notes ,
}
now := time . Now ( )
adjustmentRecord . UsedAt = & now
if err := s . redeemCodeRepo . Create ( ctx , adjustmentRecord ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "failed to create balance adjustment redeem code: %v" , err )
2025-12-23 16:29:57 +08:00
}
}
2025-12-18 13:50:39 +08:00
return user , nil
}
2026-01-04 19:27:53 +08:00
func ( s * adminServiceImpl ) GetUserAPIKeys ( ctx context . Context , userID int64 , page , pageSize int ) ( [ ] APIKey , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2026-03-04 11:29:31 +08:00
keys , result , err := s . apiKeyRepo . ListByUserID ( ctx , userID , params , APIKeyListFilters { } )
2025-12-18 13:50:39 +08:00
if err != nil {
return nil , 0 , err
}
return keys , result . Total , nil
}
2025-12-20 16:19:40 +08:00
func ( s * adminServiceImpl ) GetUserUsageStats ( ctx context . Context , userID int64 , period string ) ( any , error ) {
2025-12-18 13:50:39 +08:00
// Return mock data for now
2025-12-20 16:19:40 +08:00
return map [ string ] any {
2025-12-18 13:50:39 +08:00
"period" : period ,
"total_requests" : 0 ,
"total_cost" : 0.0 ,
"total_tokens" : 0 ,
"avg_duration_ms" : 0 ,
} , nil
}
2026-02-03 00:16:10 +08:00
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
func ( s * adminServiceImpl ) GetUserBalanceHistory ( ctx context . Context , userID int64 , page , pageSize int , codeType string ) ( [ ] RedeemCode , int64 , float64 , error ) {
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
codes , result , err := s . redeemCodeRepo . ListByUserPaginated ( ctx , userID , params , codeType )
if err != nil {
return nil , 0 , 0 , err
}
// Aggregate total recharged amount (only once, regardless of type filter)
totalRecharged , err := s . redeemCodeRepo . SumPositiveBalanceByUser ( ctx , userID )
if err != nil {
return nil , 0 , 0 , err
}
return codes , result . Total , totalRecharged , nil
}
2025-12-18 13:50:39 +08:00
// Group management implementations
2026-01-09 18:58:06 +08:00
func ( s * adminServiceImpl ) ListGroups ( ctx context . Context , page , pageSize int , platform , status , search string , isExclusive * bool ) ( [ ] Group , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2026-01-09 18:58:06 +08:00
groups , result , err := s . groupRepo . ListWithFilters ( ctx , params , platform , status , search , isExclusive )
2025-12-18 13:50:39 +08:00
if err != nil {
return nil , 0 , err
}
return groups , result . Total , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetAllGroups ( ctx context . Context ) ( [ ] Group , error ) {
2025-12-18 13:50:39 +08:00
return s . groupRepo . ListActive ( ctx )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetAllGroupsByPlatform ( ctx context . Context , platform string ) ( [ ] Group , error ) {
2025-12-18 13:50:39 +08:00
return s . groupRepo . ListActiveByPlatform ( ctx , platform )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetGroup ( ctx context . Context , id int64 ) ( * Group , error ) {
2025-12-18 13:50:39 +08:00
return s . groupRepo . GetByID ( ctx , id )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) CreateGroup ( ctx context . Context , input * CreateGroupInput ) ( * Group , error ) {
2025-12-18 13:50:39 +08:00
platform := input . Platform
if platform == "" {
2025-12-26 15:40:24 +08:00
platform = PlatformAnthropic
2025-12-18 13:50:39 +08:00
}
subscriptionType := input . SubscriptionType
if subscriptionType == "" {
2025-12-26 15:40:24 +08:00
subscriptionType = SubscriptionTypeStandard
2025-12-18 13:50:39 +08:00
}
2026-03-15 15:47:10 +08:00
// 限额字段: nil/负数 表示"无限制", 0 表示"不允许用量",正数表示具体限额
2025-12-31 22:48:35 +08:00
dailyLimit := normalizeLimit ( input . DailyLimitUSD )
weeklyLimit := normalizeLimit ( input . WeeklyLimitUSD )
monthlyLimit := normalizeLimit ( input . MonthlyLimitUSD )
2026-01-05 17:14:06 +08:00
// 图片价格: 负数表示清除( 使用默认价格) , 0 保留(表示免费)
imagePrice1K := normalizePrice ( input . ImagePrice1K )
imagePrice2K := normalizePrice ( input . ImagePrice2K )
imagePrice4K := normalizePrice ( input . ImagePrice4K )
2026-01-31 20:22:22 +08:00
soraImagePrice360 := normalizePrice ( input . SoraImagePrice360 )
soraImagePrice540 := normalizePrice ( input . SoraImagePrice540 )
soraVideoPrice := normalizePrice ( input . SoraVideoPricePerRequest )
soraVideoPriceHD := normalizePrice ( input . SoraVideoPricePerRequestHD )
2026-01-05 17:14:06 +08:00
2026-01-08 23:07:00 +08:00
// 校验降级分组
if input . FallbackGroupID != nil {
if err := s . validateFallbackGroup ( ctx , 0 , * input . FallbackGroupID ) ; err != nil {
return nil , err
}
}
2026-01-23 22:24:46 +08:00
fallbackOnInvalidRequest := input . FallbackGroupIDOnInvalidRequest
if fallbackOnInvalidRequest != nil && * fallbackOnInvalidRequest <= 0 {
fallbackOnInvalidRequest = nil
}
// 校验无效请求兜底分组
if fallbackOnInvalidRequest != nil {
if err := s . validateFallbackGroupOnInvalidRequest ( ctx , 0 , platform , subscriptionType , * fallbackOnInvalidRequest ) ; err != nil {
return nil , err
}
}
2026-01-08 23:07:00 +08:00
2026-01-27 13:09:56 +08:00
// MCPXMLInject: 默认为 true, 仅当显式传入 false 时关闭
mcpXMLInject := true
if input . MCPXMLInject != nil {
mcpXMLInject = * input . MCPXMLInject
}
2026-01-08 23:07:00 +08:00
2026-02-02 16:46:25 +08:00
// 如果指定了复制账号的源分组,先获取账号 ID 列表
var accountIDsToCopy [ ] int64
if len ( input . CopyAccountsFromGroupIDs ) > 0 {
// 去重源分组 IDs
seen := make ( map [ int64 ] struct { } )
uniqueSourceGroupIDs := make ( [ ] int64 , 0 , len ( input . CopyAccountsFromGroupIDs ) )
for _ , srcGroupID := range input . CopyAccountsFromGroupIDs {
if _ , exists := seen [ srcGroupID ] ; ! exists {
seen [ srcGroupID ] = struct { } { }
uniqueSourceGroupIDs = append ( uniqueSourceGroupIDs , srcGroupID )
}
}
// 校验源分组的平台是否与新分组一致
for _ , srcGroupID := range uniqueSourceGroupIDs {
srcGroup , err := s . groupRepo . GetByIDLite ( ctx , srcGroupID )
if err != nil {
return nil , fmt . Errorf ( "source group %d not found: %w" , srcGroupID , err )
}
if srcGroup . Platform != platform {
return nil , fmt . Errorf ( "source group %d platform mismatch: expected %s, got %s" , srcGroupID , platform , srcGroup . Platform )
}
}
// 获取所有源分组的账号(去重)
var err error
accountIDsToCopy , err = s . groupRepo . GetAccountIDsByGroupIDs ( ctx , uniqueSourceGroupIDs )
if err != nil {
return nil , fmt . Errorf ( "failed to get accounts from source groups: %w" , err )
}
}
2025-12-26 15:40:24 +08:00
group := & Group {
2026-01-23 22:24:46 +08:00
Name : input . Name ,
Description : input . Description ,
Platform : platform ,
RateMultiplier : input . RateMultiplier ,
IsExclusive : input . IsExclusive ,
Status : StatusActive ,
SubscriptionType : subscriptionType ,
DailyLimitUSD : dailyLimit ,
WeeklyLimitUSD : weeklyLimit ,
MonthlyLimitUSD : monthlyLimit ,
ImagePrice1K : imagePrice1K ,
ImagePrice2K : imagePrice2K ,
ImagePrice4K : imagePrice4K ,
2026-02-04 20:35:09 +08:00
SoraImagePrice360 : soraImagePrice360 ,
SoraImagePrice540 : soraImagePrice540 ,
SoraVideoPricePerRequest : soraVideoPrice ,
SoraVideoPricePerRequestHD : soraVideoPriceHD ,
2026-01-23 22:24:46 +08:00
ClaudeCodeOnly : input . ClaudeCodeOnly ,
FallbackGroupID : input . FallbackGroupID ,
FallbackGroupIDOnInvalidRequest : fallbackOnInvalidRequest ,
ModelRouting : input . ModelRouting ,
2026-01-27 13:09:56 +08:00
MCPXMLInject : mcpXMLInject ,
2026-02-02 22:20:08 +08:00
SupportedModelScopes : input . SupportedModelScopes ,
2026-02-28 15:01:20 +08:00
SoraStorageQuotaBytes : input . SoraStorageQuotaBytes ,
2026-03-07 17:02:19 +08:00
AllowMessagesDispatch : input . AllowMessagesDispatch ,
DefaultMappedModel : input . DefaultMappedModel ,
2025-12-18 13:50:39 +08:00
}
if err := s . groupRepo . Create ( ctx , group ) ; err != nil {
return nil , err
}
2026-02-02 16:46:25 +08:00
// 如果有需要复制的账号,绑定到新分组
if len ( accountIDsToCopy ) > 0 {
if err := s . groupRepo . BindAccountsToGroup ( ctx , group . ID , accountIDsToCopy ) ; err != nil {
return nil , fmt . Errorf ( "failed to bind accounts to new group: %w" , err )
}
group . AccountCount = int64 ( len ( accountIDsToCopy ) )
}
2025-12-18 13:50:39 +08:00
return group , nil
}
2026-03-15 15:47:10 +08:00
// normalizeLimit 将负数转换为 nil( 表示无限制) , 0 保留(表示限额为零)
2025-12-31 22:48:35 +08:00
func normalizeLimit ( limit * float64 ) * float64 {
2026-03-15 15:47:10 +08:00
if limit == nil || * limit < 0 {
2025-12-31 22:48:35 +08:00
return nil
}
return limit
}
2026-01-05 17:14:06 +08:00
// normalizePrice 将负数转换为 nil( 表示使用默认价格) , 0 保留(表示免费)
func normalizePrice ( price * float64 ) * float64 {
if price == nil || * price < 0 {
return nil
}
return price
}
2026-01-08 23:07:00 +08:00
// validateFallbackGroup 校验降级分组的有效性
// currentGroupID: 当前分组 ID( 新建时为 0)
// fallbackGroupID: 降级分组 ID
func ( s * adminServiceImpl ) validateFallbackGroup ( ctx context . Context , currentGroupID , fallbackGroupID int64 ) error {
// 不能将自己设置为降级分组
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
return fmt . Errorf ( "cannot set self as fallback group" )
}
2026-01-10 07:56:50 +08:00
visited := map [ int64 ] struct { } { }
nextID := fallbackGroupID
for {
if _ , seen := visited [ nextID ] ; seen {
return fmt . Errorf ( "fallback group cycle detected" )
}
visited [ nextID ] = struct { } { }
if currentGroupID > 0 && nextID == currentGroupID {
return fmt . Errorf ( "fallback group cycle detected" )
}
2026-01-08 23:07:00 +08:00
2026-01-10 07:56:50 +08:00
// 检查降级分组是否存在
fallbackGroup , err := s . groupRepo . GetByIDLite ( ctx , nextID )
if err != nil {
return fmt . Errorf ( "fallback group not found: %w" , err )
}
2026-01-08 23:07:00 +08:00
2026-01-10 07:56:50 +08:00
// 降级分组不能启用 claude_code_only, 否则会造成死循环
if nextID == fallbackGroupID && fallbackGroup . ClaudeCodeOnly {
return fmt . Errorf ( "fallback group cannot have claude_code_only enabled" )
}
if fallbackGroup . FallbackGroupID == nil {
return nil
}
nextID = * fallbackGroup . FallbackGroupID
}
2026-01-08 23:07:00 +08:00
}
2026-01-23 22:24:46 +08:00
// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性
// currentGroupID: 当前分组 ID( 新建时为 0)
// platform/subscriptionType: 当前分组的有效平台/订阅类型
// fallbackGroupID: 兜底分组 ID
func ( s * adminServiceImpl ) validateFallbackGroupOnInvalidRequest ( ctx context . Context , currentGroupID int64 , platform , subscriptionType string , fallbackGroupID int64 ) error {
if platform != PlatformAnthropic && platform != PlatformAntigravity {
return fmt . Errorf ( "invalid request fallback only supported for anthropic or antigravity groups" )
}
if subscriptionType == SubscriptionTypeSubscription {
return fmt . Errorf ( "subscription groups cannot set invalid request fallback" )
}
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
return fmt . Errorf ( "cannot set self as invalid request fallback group" )
}
fallbackGroup , err := s . groupRepo . GetByIDLite ( ctx , fallbackGroupID )
if err != nil {
return fmt . Errorf ( "fallback group not found: %w" , err )
}
if fallbackGroup . Platform != PlatformAnthropic {
return fmt . Errorf ( "fallback group must be anthropic platform" )
}
if fallbackGroup . SubscriptionType == SubscriptionTypeSubscription {
return fmt . Errorf ( "fallback group cannot be subscription type" )
}
if fallbackGroup . FallbackGroupIDOnInvalidRequest != nil {
return fmt . Errorf ( "fallback group cannot have invalid request fallback configured" )
}
return nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) UpdateGroup ( ctx context . Context , id int64 , input * UpdateGroupInput ) ( * Group , error ) {
2025-12-18 13:50:39 +08:00
group , err := s . groupRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
if input . Name != "" {
group . Name = input . Name
}
if input . Description != "" {
group . Description = input . Description
}
if input . Platform != "" {
group . Platform = input . Platform
}
if input . RateMultiplier != nil {
group . RateMultiplier = * input . RateMultiplier
}
if input . IsExclusive != nil {
group . IsExclusive = * input . IsExclusive
}
if input . Status != "" {
group . Status = input . Status
}
// 订阅相关字段
if input . SubscriptionType != "" {
group . SubscriptionType = input . SubscriptionType
}
2026-03-15 15:47:10 +08:00
// 限额字段: nil/负数 表示"无限制", 0 表示"不允许用量",正数表示具体限额
// 前端始终发送这三个字段,无需 nil 守卫
group . DailyLimitUSD = normalizeLimit ( input . DailyLimitUSD )
group . WeeklyLimitUSD = normalizeLimit ( input . WeeklyLimitUSD )
group . MonthlyLimitUSD = normalizeLimit ( input . MonthlyLimitUSD )
2026-01-05 17:14:06 +08:00
// 图片生成计费配置:负数表示清除(使用默认价格)
2026-01-05 17:07:29 +08:00
if input . ImagePrice1K != nil {
2026-01-05 17:14:06 +08:00
group . ImagePrice1K = normalizePrice ( input . ImagePrice1K )
2026-01-05 17:07:29 +08:00
}
if input . ImagePrice2K != nil {
2026-01-05 17:14:06 +08:00
group . ImagePrice2K = normalizePrice ( input . ImagePrice2K )
2026-01-05 17:07:29 +08:00
}
if input . ImagePrice4K != nil {
2026-01-05 17:14:06 +08:00
group . ImagePrice4K = normalizePrice ( input . ImagePrice4K )
2026-01-05 17:07:29 +08:00
}
2026-01-31 20:22:22 +08:00
if input . SoraImagePrice360 != nil {
group . SoraImagePrice360 = normalizePrice ( input . SoraImagePrice360 )
}
if input . SoraImagePrice540 != nil {
group . SoraImagePrice540 = normalizePrice ( input . SoraImagePrice540 )
}
if input . SoraVideoPricePerRequest != nil {
group . SoraVideoPricePerRequest = normalizePrice ( input . SoraVideoPricePerRequest )
}
if input . SoraVideoPricePerRequestHD != nil {
group . SoraVideoPricePerRequestHD = normalizePrice ( input . SoraVideoPricePerRequestHD )
}
2026-02-28 15:01:20 +08:00
if input . SoraStorageQuotaBytes != nil {
group . SoraStorageQuotaBytes = * input . SoraStorageQuotaBytes
}
2025-12-18 13:50:39 +08:00
2026-01-08 23:07:00 +08:00
// Claude Code 客户端限制
if input . ClaudeCodeOnly != nil {
group . ClaudeCodeOnly = * input . ClaudeCodeOnly
}
if input . FallbackGroupID != nil {
// 校验降级分组
if * input . FallbackGroupID > 0 {
if err := s . validateFallbackGroup ( ctx , id , * input . FallbackGroupID ) ; err != nil {
return nil , err
}
group . FallbackGroupID = input . FallbackGroupID
} else {
// 传入 0 或负数表示清除降级分组
group . FallbackGroupID = nil
}
}
2026-01-23 22:24:46 +08:00
fallbackOnInvalidRequest := group . FallbackGroupIDOnInvalidRequest
if input . FallbackGroupIDOnInvalidRequest != nil {
if * input . FallbackGroupIDOnInvalidRequest > 0 {
fallbackOnInvalidRequest = input . FallbackGroupIDOnInvalidRequest
} else {
fallbackOnInvalidRequest = nil
}
}
if fallbackOnInvalidRequest != nil {
if err := s . validateFallbackGroupOnInvalidRequest ( ctx , id , group . Platform , group . SubscriptionType , * fallbackOnInvalidRequest ) ; err != nil {
return nil , err
}
}
group . FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest
2026-01-08 23:07:00 +08:00
2026-01-16 17:26:05 +08:00
// 模型路由配置
if input . ModelRouting != nil {
group . ModelRouting = input . ModelRouting
}
if input . ModelRoutingEnabled != nil {
group . ModelRoutingEnabled = * input . ModelRoutingEnabled
}
2026-01-27 13:09:56 +08:00
if input . MCPXMLInject != nil {
group . MCPXMLInject = * input . MCPXMLInject
}
2026-01-16 17:26:05 +08:00
2026-02-02 22:20:08 +08:00
// 支持的模型系列(仅 antigravity 平台使用)
if input . SupportedModelScopes != nil {
group . SupportedModelScopes = * input . SupportedModelScopes
}
2026-01-16 17:26:05 +08:00
2026-03-07 17:02:19 +08:00
// OpenAI Messages 调度配置
if input . AllowMessagesDispatch != nil {
group . AllowMessagesDispatch = * input . AllowMessagesDispatch
}
if input . DefaultMappedModel != nil {
group . DefaultMappedModel = * input . DefaultMappedModel
}
2025-12-18 13:50:39 +08:00
if err := s . groupRepo . Update ( ctx , group ) ; err != nil {
return nil , err
}
2026-02-02 16:46:25 +08:00
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
if len ( input . CopyAccountsFromGroupIDs ) > 0 {
// 去重源分组 IDs
seen := make ( map [ int64 ] struct { } )
uniqueSourceGroupIDs := make ( [ ] int64 , 0 , len ( input . CopyAccountsFromGroupIDs ) )
for _ , srcGroupID := range input . CopyAccountsFromGroupIDs {
// 校验:源分组不能是自身
if srcGroupID == id {
return nil , fmt . Errorf ( "cannot copy accounts from self" )
}
// 去重
if _ , exists := seen [ srcGroupID ] ; ! exists {
seen [ srcGroupID ] = struct { } { }
uniqueSourceGroupIDs = append ( uniqueSourceGroupIDs , srcGroupID )
}
}
// 校验源分组的平台是否与当前分组一致
for _ , srcGroupID := range uniqueSourceGroupIDs {
srcGroup , err := s . groupRepo . GetByIDLite ( ctx , srcGroupID )
if err != nil {
return nil , fmt . Errorf ( "source group %d not found: %w" , srcGroupID , err )
}
if srcGroup . Platform != group . Platform {
return nil , fmt . Errorf ( "source group %d platform mismatch: expected %s, got %s" , srcGroupID , group . Platform , srcGroup . Platform )
}
}
// 获取所有源分组的账号(去重)
accountIDsToCopy , err := s . groupRepo . GetAccountIDsByGroupIDs ( ctx , uniqueSourceGroupIDs )
if err != nil {
return nil , fmt . Errorf ( "failed to get accounts from source groups: %w" , err )
}
// 先清空当前分组的所有账号绑定
if _ , err := s . groupRepo . DeleteAccountGroupsByGroupID ( ctx , id ) ; err != nil {
return nil , fmt . Errorf ( "failed to clear existing account bindings: %w" , err )
}
// 再绑定源分组的账号
if len ( accountIDsToCopy ) > 0 {
if err := s . groupRepo . BindAccountsToGroup ( ctx , id , accountIDsToCopy ) ; err != nil {
return nil , fmt . Errorf ( "failed to bind accounts to group: %w" , err )
}
}
}
2026-01-10 22:23:51 +08:00
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByGroupID ( ctx , id )
}
2025-12-18 13:50:39 +08:00
return group , nil
}
func ( s * adminServiceImpl ) DeleteGroup ( ctx context . Context , id int64 ) error {
2026-01-10 22:23:51 +08:00
var groupKeys [ ] string
if s . authCacheInvalidator != nil {
keys , err := s . apiKeyRepo . ListKeysByGroupID ( ctx , id )
if err == nil {
groupKeys = keys
}
}
2025-12-25 20:52:47 +08:00
affectedUserIDs , err := s . groupRepo . DeleteCascade ( ctx , id )
2025-12-18 13:50:39 +08:00
if err != nil {
return err
}
2026-02-05 16:00:34 +08:00
// 注意: user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理
2025-12-18 13:50:39 +08:00
// 事务成功后,异步失效受影响用户的订阅缓存
if len ( affectedUserIDs ) > 0 && s . billingCacheService != nil {
groupID := id
go func ( ) {
cacheCtx , cancel := context . WithTimeout ( context . Background ( ) , 30 * time . Second )
defer cancel ( )
for _ , userID := range affectedUserIDs {
2025-12-20 15:29:52 +08:00
if err := s . billingCacheService . InvalidateSubscription ( cacheCtx , userID , groupID ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "invalidate subscription cache failed: user_id=%d group_id=%d err=%v" , userID , groupID , err )
2025-12-20 15:29:52 +08:00
}
2025-12-18 13:50:39 +08:00
}
} ( )
}
2026-01-10 22:23:51 +08:00
if s . authCacheInvalidator != nil {
for _ , key := range groupKeys {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , key )
}
}
2025-12-18 13:50:39 +08:00
return nil
}
2026-01-04 19:27:53 +08:00
func ( s * adminServiceImpl ) GetGroupAPIKeys ( ctx context . Context , groupID int64 , page , pageSize int ) ( [ ] APIKey , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2025-12-18 13:50:39 +08:00
keys , result , err := s . apiKeyRepo . ListByGroupID ( ctx , groupID , params )
if err != nil {
return nil , 0 , err
}
return keys , result . Total , nil
}
2026-03-12 23:37:36 +08:00
func ( s * adminServiceImpl ) GetGroupRateMultipliers ( ctx context . Context , groupID int64 ) ( [ ] UserGroupRateEntry , error ) {
if s . userGroupRateRepo == nil {
return nil , nil
}
return s . userGroupRateRepo . GetByGroupID ( ctx , groupID )
}
func ( s * adminServiceImpl ) ClearGroupRateMultipliers ( ctx context . Context , groupID int64 ) error {
if s . userGroupRateRepo == nil {
return nil
}
return s . userGroupRateRepo . DeleteByGroupID ( ctx , groupID )
}
func ( s * adminServiceImpl ) BatchSetGroupRateMultipliers ( ctx context . Context , groupID int64 , entries [ ] GroupRateMultiplierInput ) error {
if s . userGroupRateRepo == nil {
return nil
}
return s . userGroupRateRepo . SyncGroupRateMultipliers ( ctx , groupID , entries )
}
2026-02-08 16:53:45 +08:00
func ( s * adminServiceImpl ) UpdateGroupSortOrders ( ctx context . Context , updates [ ] GroupSortOrderUpdate ) error {
return s . groupRepo . UpdateSortOrders ( ctx , updates )
}
2026-02-28 00:07:44 +08:00
// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定
// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组
2026-02-28 17:33:30 +08:00
func ( s * adminServiceImpl ) AdminUpdateAPIKeyGroupID ( ctx context . Context , keyID int64 , groupID * int64 ) ( * AdminUpdateAPIKeyGroupIDResult , error ) {
2026-02-28 00:07:44 +08:00
apiKey , err := s . apiKeyRepo . GetByID ( ctx , keyID )
if err != nil {
return nil , err
}
if groupID == nil {
// nil 表示不修改,直接返回
2026-02-28 17:33:30 +08:00
return & AdminUpdateAPIKeyGroupIDResult { APIKey : apiKey } , nil
2026-02-28 00:07:44 +08:00
}
if * groupID < 0 {
return nil , infraerrors . BadRequest ( "INVALID_GROUP_ID" , "group_id must be non-negative" )
}
2026-02-28 17:33:30 +08:00
result := & AdminUpdateAPIKeyGroupIDResult { }
2026-02-28 00:07:44 +08:00
if * groupID == 0 {
2026-02-28 17:33:30 +08:00
// 0 表示解绑分组(不修改 user_allowed_groups, 避免影响用户其他 Key)
2026-02-28 00:07:44 +08:00
apiKey . GroupID = nil
apiKey . Group = nil
} else {
// 验证目标分组存在且状态为 active
group , err := s . groupRepo . GetByID ( ctx , * groupID )
if err != nil {
return nil , err
}
if group . Status != StatusActive {
return nil , infraerrors . BadRequest ( "GROUP_NOT_ACTIVE" , "target group is not active" )
}
2026-03-10 00:51:43 +08:00
// 订阅类型分组:用户须持有该分组的有效订阅才可绑定
2026-02-28 17:33:30 +08:00
if group . IsSubscriptionType ( ) {
2026-03-10 10:53:54 +08:00
if s . userSubRepo == nil {
return nil , infraerrors . InternalServer ( "SUBSCRIPTION_REPOSITORY_UNAVAILABLE" , "subscription repository is not configured" )
}
2026-03-10 00:51:43 +08:00
if _ , err := s . userSubRepo . GetActiveByUserIDAndGroupID ( ctx , apiKey . UserID , * groupID ) ; err != nil {
if errors . Is ( err , ErrSubscriptionNotFound ) {
return nil , infraerrors . BadRequest ( "SUBSCRIPTION_REQUIRED" , "user does not have an active subscription for this group" )
}
return nil , err
}
2026-02-28 17:33:30 +08:00
}
2026-02-28 00:07:44 +08:00
gid := * groupID
apiKey . GroupID = & gid
apiKey . Group = group
2026-02-28 17:33:30 +08:00
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
2026-03-10 10:53:54 +08:00
if group . IsExclusive && ! group . IsSubscriptionType ( ) {
2026-02-28 17:33:30 +08:00
opCtx := ctx
var tx * dbent . Tx
if s . entClient == nil {
logger . LegacyPrintf ( "service.admin" , "Warning: entClient is nil, skipping transaction protection for exclusive group binding" )
} else {
var txErr error
tx , txErr = s . entClient . Tx ( ctx )
if txErr != nil {
return nil , fmt . Errorf ( "begin transaction: %w" , txErr )
}
defer func ( ) { _ = tx . Rollback ( ) } ( )
opCtx = dbent . NewTxContext ( ctx , tx )
}
if addErr := s . userRepo . AddGroupToAllowedGroups ( opCtx , apiKey . UserID , gid ) ; addErr != nil {
return nil , fmt . Errorf ( "add group to user allowed groups: %w" , addErr )
}
if err := s . apiKeyRepo . Update ( opCtx , apiKey ) ; err != nil {
return nil , fmt . Errorf ( "update api key: %w" , err )
}
if tx != nil {
if err := tx . Commit ( ) ; err != nil {
return nil , fmt . Errorf ( "commit transaction: %w" , err )
}
}
result . AutoGrantedGroupAccess = true
result . GrantedGroupID = & gid
result . GrantedGroupName = group . Name
// 失效认证缓存(在事务提交后执行)
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , apiKey . Key )
}
result . APIKey = apiKey
return result , nil
}
2026-02-28 00:07:44 +08:00
}
2026-02-28 17:33:30 +08:00
// 非专属分组 / 解绑:无需事务,单步更新即可
2026-02-28 00:07:44 +08:00
if err := s . apiKeyRepo . Update ( ctx , apiKey ) ; err != nil {
return nil , fmt . Errorf ( "update api key: %w" , err )
}
// 失效认证缓存
if s . authCacheInvalidator != nil {
s . authCacheInvalidator . InvalidateAuthCacheByKey ( ctx , apiKey . Key )
}
2026-02-28 17:33:30 +08:00
result . APIKey = apiKey
return result , nil
2026-02-28 00:07:44 +08:00
}
2025-12-18 13:50:39 +08:00
// Account management implementations
2026-02-12 03:47:06 +08:00
func ( s * adminServiceImpl ) ListAccounts ( ctx context . Context , page , pageSize int , platform , accountType , status , search string , groupID int64 ) ( [ ] Account , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2026-02-12 03:47:06 +08:00
accounts , result , err := s . accountRepo . ListWithFilters ( ctx , params , platform , accountType , status , search , groupID )
2025-12-18 13:50:39 +08:00
if err != nil {
return nil , 0 , err
}
2026-03-08 00:14:15 +08:00
now := time . Now ( )
for i := range accounts {
syncOpenAICodexRateLimitFromExtra ( ctx , s . accountRepo , & accounts [ i ] , now )
}
2025-12-18 13:50:39 +08:00
return accounts , result . Total , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetAccount ( ctx context . Context , id int64 ) ( * Account , error ) {
2025-12-18 13:50:39 +08:00
return s . accountRepo . GetByID ( ctx , id )
}
2026-01-01 15:07:16 +08:00
func ( s * adminServiceImpl ) GetAccountsByIDs ( ctx context . Context , ids [ ] int64 ) ( [ ] * Account , error ) {
if len ( ids ) == 0 {
return [ ] * Account { } , nil
}
accounts , err := s . accountRepo . GetByIDs ( ctx , ids )
if err != nil {
return nil , fmt . Errorf ( "failed to get accounts by IDs: %w" , err )
}
return accounts , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) CreateAccount ( ctx context . Context , input * CreateAccountInput ) ( * Account , error ) {
2025-12-18 13:50:39 +08:00
// 绑定分组
2025-12-29 03:17:25 +08:00
groupIDs := input . GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
2026-02-05 17:46:08 +08:00
if len ( groupIDs ) == 0 && ! input . SkipDefaultGroupBind {
2025-12-29 03:17:25 +08:00
defaultGroupName := input . Platform + "-default"
groups , err := s . groupRepo . ListActiveByPlatform ( ctx , input . Platform )
if err == nil {
for _ , g := range groups {
if g . Name == defaultGroupName {
groupIDs = [ ] int64 { g . ID }
break
}
}
}
}
2026-01-03 06:34:00 -08:00
// 检查混合渠道风险(除非用户已确认)
if len ( groupIDs ) > 0 && ! input . SkipMixedChannelCheck {
if err := s . checkMixedChannelRisk ( ctx , 0 , input . Platform , groupIDs ) ; err != nil {
return nil , err
}
}
2026-02-28 15:01:20 +08:00
// Sora apikey 账号的 base_url 必填校验
if input . Platform == PlatformSora && input . Type == AccountTypeAPIKey {
baseURL , _ := input . Credentials [ "base_url" ] . ( string )
baseURL = strings . TrimSpace ( baseURL )
if baseURL == "" {
return nil , errors . New ( "sora apikey 账号必须设置 base_url" )
}
if ! strings . HasPrefix ( baseURL , "http://" ) && ! strings . HasPrefix ( baseURL , "https://" ) {
return nil , errors . New ( "base_url 必须以 http:// 或 https:// 开头" )
}
}
2026-01-03 06:34:00 -08:00
account := & Account {
Name : input . Name ,
2026-01-05 14:07:33 +08:00
Notes : normalizeAccountNotes ( input . Notes ) ,
2026-01-03 06:34:00 -08:00
Platform : input . Platform ,
Type : input . Type ,
Credentials : input . Credentials ,
2026-03-12 22:36:25 +08:00
Extra : input . Extra ,
2026-01-03 06:34:00 -08:00
ProxyID : input . ProxyID ,
Concurrency : input . Concurrency ,
Priority : input . Priority ,
Status : StatusActive ,
2026-01-04 10:45:18 +08:00
Schedulable : true ,
2026-01-03 06:34:00 -08:00
}
2026-03-13 11:12:37 +08:00
// 预计算固定时间重置的下次重置时间
if account . Extra != nil {
if err := ValidateQuotaResetConfig ( account . Extra ) ; err != nil {
return nil , err
}
ComputeQuotaResetAt ( account . Extra )
}
2026-01-07 16:59:35 +08:00
if input . ExpiresAt != nil && * input . ExpiresAt > 0 {
expiresAt := time . Unix ( * input . ExpiresAt , 0 )
account . ExpiresAt = & expiresAt
}
if input . AutoPauseOnExpired != nil {
account . AutoPauseOnExpired = * input . AutoPauseOnExpired
} else {
account . AutoPauseOnExpired = true
}
2026-01-14 16:12:08 +08:00
if input . RateMultiplier != nil {
if * input . RateMultiplier < 0 {
return nil , errors . New ( "rate_multiplier must be >= 0" )
}
account . RateMultiplier = input . RateMultiplier
}
2026-03-06 05:07:10 +08:00
if input . LoadFactor != nil && * input . LoadFactor > 0 {
2026-03-06 04:27:41 +08:00
if * input . LoadFactor > 10000 {
return nil , errors . New ( "load_factor must be <= 10000" )
}
2026-03-06 05:07:10 +08:00
account . LoadFactor = input . LoadFactor
}
2026-01-03 06:34:00 -08:00
if err := s . accountRepo . Create ( ctx , account ) ; err != nil {
return nil , err
}
2026-01-30 14:08:04 +08:00
// 如果是 Sora 平台账号,自动创建 sora_accounts 扩展表记录
if account . Platform == PlatformSora && s . soraAccountRepo != nil {
soraUpdates := map [ string ] any {
"access_token" : account . GetCredential ( "access_token" ) ,
"refresh_token" : account . GetCredential ( "refresh_token" ) ,
}
if err := s . soraAccountRepo . Upsert ( ctx , account . ID , soraUpdates ) ; err != nil {
// 只记录警告日志,不阻塞账号创建
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "[AdminService] 创建 sora_accounts 记录失败: account_id=%d err=%v" , account . ID , err )
2026-01-30 14:08:04 +08:00
}
}
2026-01-03 06:34:00 -08:00
// 绑定分组
2025-12-29 03:17:25 +08:00
if len ( groupIDs ) > 0 {
if err := s . accountRepo . BindGroups ( ctx , account . ID , groupIDs ) ; err != nil {
2025-12-18 13:50:39 +08:00
return nil , err
}
}
2025-12-29 03:17:25 +08:00
2025-12-18 13:50:39 +08:00
return account , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) UpdateAccount ( ctx context . Context , id int64 , input * UpdateAccountInput ) ( * Account , error ) {
2025-12-18 13:50:39 +08:00
account , err := s . accountRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
2026-03-15 23:18:35 +08:00
wasOveragesEnabled := account . IsOveragesEnabled ( )
2025-12-18 13:50:39 +08:00
if input . Name != "" {
account . Name = input . Name
}
if input . Type != "" {
account . Type = input . Type
}
2026-01-05 14:07:33 +08:00
if input . Notes != nil {
account . Notes = normalizeAccountNotes ( input . Notes )
}
2025-12-20 15:29:52 +08:00
if len ( input . Credentials ) > 0 {
2025-12-26 15:40:24 +08:00
account . Credentials = input . Credentials
2025-12-18 13:50:39 +08:00
}
2026-03-16 16:22:31 +08:00
// Extra 使用 map: 需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
if input . Extra != nil {
2026-03-07 19:06:59 +08:00
// 保留配额用量字段,防止编辑账号时意外重置
for _ , key := range [ ] string { "quota_used" , "quota_daily_used" , "quota_daily_start" , "quota_weekly_used" , "quota_weekly_start" } {
if v , ok := account . Extra [ key ] ; ok {
input . Extra [ key ] = v
}
2026-03-05 20:54:37 +08:00
}
2025-12-26 15:40:24 +08:00
account . Extra = input . Extra
2026-03-16 00:45:17 +08:00
if account . Platform == PlatformAntigravity && wasOveragesEnabled && ! account . IsOveragesEnabled ( ) {
2026-03-16 04:31:22 +08:00
delete ( account . Extra , "antigravity_credits_overages" ) // 清理旧版 overages 运行态
// 清除 AICredits 限流 key
if rawLimits , ok := account . Extra [ modelRateLimitsKey ] . ( map [ string ] any ) ; ok {
delete ( rawLimits , creditsExhaustedKey )
}
2026-03-16 00:45:17 +08:00
}
if account . Platform == PlatformAntigravity && ! wasOveragesEnabled && account . IsOveragesEnabled ( ) {
delete ( account . Extra , modelRateLimitsKey )
2026-03-16 04:31:22 +08:00
delete ( account . Extra , "antigravity_credits_overages" ) // 清理旧版 overages 运行态
2026-03-16 00:45:17 +08:00
}
2026-03-13 11:12:37 +08:00
// 校验并预计算固定时间重置的下次重置时间
if err := ValidateQuotaResetConfig ( account . Extra ) ; err != nil {
return nil , err
}
ComputeQuotaResetAt ( account . Extra )
2025-12-18 13:50:39 +08:00
}
if input . ProxyID != nil {
2026-01-05 20:53:38 +08:00
// 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图)
if * input . ProxyID == 0 {
account . ProxyID = nil
} else {
account . ProxyID = input . ProxyID
}
2025-12-25 21:58:09 +08:00
account . Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID
2025-12-18 13:50:39 +08:00
}
// 只在指针非 nil 时更新 Concurrency( 支持设置为 0)
if input . Concurrency != nil {
account . Concurrency = * input . Concurrency
}
// 只在指针非 nil 时更新 Priority( 支持设置为 0)
if input . Priority != nil {
account . Priority = * input . Priority
}
2026-01-14 16:12:08 +08:00
if input . RateMultiplier != nil {
if * input . RateMultiplier < 0 {
return nil , errors . New ( "rate_multiplier must be >= 0" )
}
account . RateMultiplier = input . RateMultiplier
}
2026-03-06 05:07:10 +08:00
if input . LoadFactor != nil {
if * input . LoadFactor <= 0 {
account . LoadFactor = nil // 0 或负数表示清除
2026-03-06 04:27:41 +08:00
} else if * input . LoadFactor > 10000 {
return nil , errors . New ( "load_factor must be <= 10000" )
2026-03-06 05:07:10 +08:00
} else {
account . LoadFactor = input . LoadFactor
}
}
2025-12-18 13:50:39 +08:00
if input . Status != "" {
account . Status = input . Status
}
2026-01-07 16:59:35 +08:00
if input . ExpiresAt != nil {
if * input . ExpiresAt <= 0 {
account . ExpiresAt = nil
} else {
expiresAt := time . Unix ( * input . ExpiresAt , 0 )
account . ExpiresAt = & expiresAt
}
}
if input . AutoPauseOnExpired != nil {
account . AutoPauseOnExpired = * input . AutoPauseOnExpired
}
2025-12-18 13:50:39 +08:00
2026-02-28 15:01:20 +08:00
// Sora apikey 账号的 base_url 必填校验
if account . Platform == PlatformSora && account . Type == AccountTypeAPIKey {
baseURL , _ := account . Credentials [ "base_url" ] . ( string )
baseURL = strings . TrimSpace ( baseURL )
if baseURL == "" {
return nil , errors . New ( "sora apikey 账号必须设置 base_url" )
}
if ! strings . HasPrefix ( baseURL , "http://" ) && ! strings . HasPrefix ( baseURL , "https://" ) {
return nil , errors . New ( "base_url 必须以 http:// 或 https:// 开头" )
}
}
2025-12-27 14:57:43 +08:00
// 先验证分组是否存在(在任何写操作之前)
if input . GroupIDs != nil {
2026-02-28 15:01:20 +08:00
if err := s . validateGroupIDsExist ( ctx , * input . GroupIDs ) ; err != nil {
return nil , err
2025-12-27 14:57:43 +08:00
}
2026-01-03 06:34:00 -08:00
// 检查混合渠道风险(除非用户已确认)
if ! input . SkipMixedChannelCheck {
if err := s . checkMixedChannelRisk ( ctx , account . ID , account . Platform , * input . GroupIDs ) ; err != nil {
return nil , err
}
}
2025-12-27 14:57:43 +08:00
}
2025-12-18 13:50:39 +08:00
if err := s . accountRepo . Update ( ctx , account ) ; err != nil {
return nil , err
}
2025-12-27 14:57:43 +08:00
// 绑定分组
2025-12-18 13:50:39 +08:00
if input . GroupIDs != nil {
if err := s . accountRepo . BindGroups ( ctx , account . ID , * input . GroupIDs ) ; err != nil {
return nil , err
}
}
2025-12-25 21:58:09 +08:00
// 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象)
2026-01-31 20:22:22 +08:00
updated , err := s . accountRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
return updated , nil
2025-12-18 13:50:39 +08:00
}
2025-12-24 17:16:19 -08:00
// BulkUpdateAccounts updates multiple accounts in one request.
// It merges credentials/extra keys instead of overwriting the whole object.
func ( s * adminServiceImpl ) BulkUpdateAccounts ( ctx context . Context , input * BulkUpdateAccountsInput ) ( * BulkUpdateAccountsResult , error ) {
result := & BulkUpdateAccountsResult {
2026-01-11 20:22:17 +08:00
SuccessIDs : make ( [ ] int64 , 0 , len ( input . AccountIDs ) ) ,
FailedIDs : make ( [ ] int64 , 0 , len ( input . AccountIDs ) ) ,
Results : make ( [ ] BulkUpdateAccountResult , 0 , len ( input . AccountIDs ) ) ,
2025-12-24 17:16:19 -08:00
}
if len ( input . AccountIDs ) == 0 {
return result , nil
}
2026-02-28 15:01:20 +08:00
if input . GroupIDs != nil {
if err := s . validateGroupIDsExist ( ctx , * input . GroupIDs ) ; err != nil {
return nil , err
}
}
2025-12-24 17:16:19 -08:00
2026-01-31 20:22:22 +08:00
needMixedChannelCheck := input . GroupIDs != nil && ! input . SkipMixedChannelCheck
2026-03-01 15:03:50 +08:00
// 预加载账号平台信息(混合渠道检查需要)。
2026-01-03 06:34:00 -08:00
platformByID := map [ int64 ] string { }
2026-02-01 21:37:10 +08:00
if needMixedChannelCheck {
2026-01-03 06:34:00 -08:00
accounts , err := s . accountRepo . GetByIDs ( ctx , input . AccountIDs )
2026-02-28 15:01:20 +08:00
if err != nil {
return nil , err
}
2026-03-01 14:39:07 +08:00
for _ , account := range accounts {
if account != nil {
platformByID [ account . ID ] = account . Platform
}
}
2026-01-03 06:34:00 -08:00
}
2026-02-28 19:31:57 +08:00
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
if needMixedChannelCheck {
for _ , accountID := range input . AccountIDs {
platform := platformByID [ accountID ]
if platform == "" {
continue
}
if err := s . checkMixedChannelRisk ( ctx , accountID , platform , * input . GroupIDs ) ; err != nil {
return nil , err
}
}
}
2026-01-14 16:12:08 +08:00
if input . RateMultiplier != nil {
if * input . RateMultiplier < 0 {
return nil , errors . New ( "rate_multiplier must be >= 0" )
}
}
2025-12-24 17:16:19 -08:00
// Prepare bulk updates for columns and JSONB fields.
2025-12-25 17:15:01 +08:00
repoUpdates := AccountBulkUpdate {
2025-12-24 17:16:19 -08:00
Credentials : input . Credentials ,
Extra : input . Extra ,
}
if input . Name != "" {
repoUpdates . Name = & input . Name
}
if input . ProxyID != nil {
repoUpdates . ProxyID = input . ProxyID
}
if input . Concurrency != nil {
repoUpdates . Concurrency = input . Concurrency
}
if input . Priority != nil {
repoUpdates . Priority = input . Priority
}
2026-01-14 16:12:08 +08:00
if input . RateMultiplier != nil {
repoUpdates . RateMultiplier = input . RateMultiplier
}
2026-03-06 05:07:10 +08:00
if input . LoadFactor != nil {
2026-03-06 05:17:52 +08:00
if * input . LoadFactor <= 0 {
repoUpdates . LoadFactor = nil // 0 或负数表示清除
} else if * input . LoadFactor > 10000 {
return nil , errors . New ( "load_factor must be <= 10000" )
} else {
repoUpdates . LoadFactor = input . LoadFactor
}
2026-03-06 05:07:10 +08:00
}
2025-12-24 17:16:19 -08:00
if input . Status != "" {
repoUpdates . Status = & input . Status
}
2026-01-09 19:26:32 +08:00
if input . Schedulable != nil {
repoUpdates . Schedulable = input . Schedulable
}
2025-12-24 17:16:19 -08:00
// Run bulk update for column/jsonb fields first.
if _ , err := s . accountRepo . BulkUpdate ( ctx , input . AccountIDs , repoUpdates ) ; err != nil {
return nil , err
}
// Handle group bindings per account (requires individual operations).
for _ , accountID := range input . AccountIDs {
entry := BulkUpdateAccountResult { AccountID : accountID }
if input . GroupIDs != nil {
if err := s . accountRepo . BindGroups ( ctx , accountID , * input . GroupIDs ) ; err != nil {
entry . Success = false
entry . Error = err . Error ( )
result . Failed ++
2026-01-11 20:22:17 +08:00
result . FailedIDs = append ( result . FailedIDs , accountID )
2025-12-24 17:16:19 -08:00
result . Results = append ( result . Results , entry )
continue
}
}
entry . Success = true
result . Success ++
2026-01-11 20:22:17 +08:00
result . SuccessIDs = append ( result . SuccessIDs , accountID )
2025-12-24 17:16:19 -08:00
result . Results = append ( result . Results , entry )
}
return result , nil
}
2025-12-18 13:50:39 +08:00
func ( s * adminServiceImpl ) DeleteAccount ( ctx context . Context , id int64 ) error {
2026-01-31 20:22:22 +08:00
if err := s . accountRepo . Delete ( ctx , id ) ; err != nil {
return err
}
return nil
2025-12-18 13:50:39 +08:00
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) RefreshAccountCredentials ( ctx context . Context , id int64 ) ( * Account , error ) {
2025-12-18 13:50:39 +08:00
account , err := s . accountRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
// TODO: Implement refresh logic
return account , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) ClearAccountError ( ctx context . Context , id int64 ) ( * Account , error ) {
2026-03-08 06:59:53 +08:00
if err := s . accountRepo . ClearError ( ctx , id ) ; err != nil {
2025-12-18 13:50:39 +08:00
return nil , err
}
2026-03-08 06:59:53 +08:00
return s . accountRepo . GetByID ( ctx , id )
2025-12-18 13:50:39 +08:00
}
2026-01-16 12:13:54 +08:00
func ( s * adminServiceImpl ) SetAccountError ( ctx context . Context , id int64 , errorMsg string ) error {
return s . accountRepo . SetError ( ctx , id , errorMsg )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) SetAccountSchedulable ( ctx context . Context , id int64 , schedulable bool ) ( * Account , error ) {
2025-12-18 13:50:39 +08:00
if err := s . accountRepo . SetSchedulable ( ctx , id , schedulable ) ; err != nil {
return nil , err
}
2026-01-31 20:22:22 +08:00
updated , err := s . accountRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
return updated , nil
}
2025-12-18 13:50:39 +08:00
// Proxy management implementations
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) ListProxies ( ctx context . Context , page , pageSize int , protocol , status , search string ) ( [ ] Proxy , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2025-12-18 13:50:39 +08:00
proxies , result , err := s . proxyRepo . ListWithFilters ( ctx , params , protocol , status , search )
if err != nil {
return nil , 0 , err
}
return proxies , result . Total , nil
}
2026-01-08 21:20:12 +08:00
func ( s * adminServiceImpl ) ListProxiesWithAccountCount ( ctx context . Context , page , pageSize int , protocol , status , search string ) ( [ ] ProxyWithAccountCount , int64 , error ) {
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
proxies , result , err := s . proxyRepo . ListWithFiltersAndAccountCount ( ctx , params , protocol , status , search )
if err != nil {
return nil , 0 , err
}
2026-01-14 19:45:29 +08:00
s . attachProxyLatency ( ctx , proxies )
2026-01-08 21:20:12 +08:00
return proxies , result . Total , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetAllProxies ( ctx context . Context ) ( [ ] Proxy , error ) {
2025-12-18 13:50:39 +08:00
return s . proxyRepo . ListActive ( ctx )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetAllProxiesWithAccountCount ( ctx context . Context ) ( [ ] ProxyWithAccountCount , error ) {
2026-01-14 19:45:29 +08:00
proxies , err := s . proxyRepo . ListActiveWithAccountCount ( ctx )
if err != nil {
return nil , err
}
s . attachProxyLatency ( ctx , proxies )
return proxies , nil
2025-12-18 13:50:39 +08:00
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetProxy ( ctx context . Context , id int64 ) ( * Proxy , error ) {
2025-12-18 13:50:39 +08:00
return s . proxyRepo . GetByID ( ctx , id )
}
2026-02-05 18:40:49 +08:00
func ( s * adminServiceImpl ) GetProxiesByIDs ( ctx context . Context , ids [ ] int64 ) ( [ ] Proxy , error ) {
return s . proxyRepo . ListByIDs ( ctx , ids )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) CreateProxy ( ctx context . Context , input * CreateProxyInput ) ( * Proxy , error ) {
proxy := & Proxy {
2025-12-18 13:50:39 +08:00
Name : input . Name ,
Protocol : input . Protocol ,
Host : input . Host ,
Port : input . Port ,
Username : input . Username ,
Password : input . Password ,
2025-12-26 15:40:24 +08:00
Status : StatusActive ,
2025-12-18 13:50:39 +08:00
}
if err := s . proxyRepo . Create ( ctx , proxy ) ; err != nil {
return nil , err
}
2026-01-14 19:45:29 +08:00
// Probe latency asynchronously so creation isn't blocked by network timeout.
go s . probeProxyLatency ( context . Background ( ) , proxy )
2025-12-18 13:50:39 +08:00
return proxy , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) UpdateProxy ( ctx context . Context , id int64 , input * UpdateProxyInput ) ( * Proxy , error ) {
2025-12-18 13:50:39 +08:00
proxy , err := s . proxyRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
if input . Name != "" {
proxy . Name = input . Name
}
if input . Protocol != "" {
proxy . Protocol = input . Protocol
}
if input . Host != "" {
proxy . Host = input . Host
}
if input . Port != 0 {
proxy . Port = input . Port
}
if input . Username != "" {
proxy . Username = input . Username
}
if input . Password != "" {
proxy . Password = input . Password
}
if input . Status != "" {
proxy . Status = input . Status
}
if err := s . proxyRepo . Update ( ctx , proxy ) ; err != nil {
return nil , err
}
return proxy , nil
}
func ( s * adminServiceImpl ) DeleteProxy ( ctx context . Context , id int64 ) error {
2026-01-14 19:45:29 +08:00
count , err := s . proxyRepo . CountAccountsByProxyID ( ctx , id )
if err != nil {
return err
}
if count > 0 {
return ErrProxyInUse
}
2025-12-18 13:50:39 +08:00
return s . proxyRepo . Delete ( ctx , id )
}
2026-01-14 19:45:29 +08:00
func ( s * adminServiceImpl ) BatchDeleteProxies ( ctx context . Context , ids [ ] int64 ) ( * ProxyBatchDeleteResult , error ) {
result := & ProxyBatchDeleteResult { }
if len ( ids ) == 0 {
return result , nil
}
for _ , id := range ids {
count , err := s . proxyRepo . CountAccountsByProxyID ( ctx , id )
if err != nil {
result . Skipped = append ( result . Skipped , ProxyBatchDeleteSkipped {
ID : id ,
Reason : err . Error ( ) ,
} )
continue
}
if count > 0 {
result . Skipped = append ( result . Skipped , ProxyBatchDeleteSkipped {
ID : id ,
Reason : ErrProxyInUse . Error ( ) ,
} )
continue
}
if err := s . proxyRepo . Delete ( ctx , id ) ; err != nil {
result . Skipped = append ( result . Skipped , ProxyBatchDeleteSkipped {
ID : id ,
Reason : err . Error ( ) ,
} )
continue
}
result . DeletedIDs = append ( result . DeletedIDs , id )
}
return result , nil
}
func ( s * adminServiceImpl ) GetProxyAccounts ( ctx context . Context , proxyID int64 ) ( [ ] ProxyAccountSummary , error ) {
return s . proxyRepo . ListAccountSummariesByProxyID ( ctx , proxyID )
2025-12-18 13:50:39 +08:00
}
func ( s * adminServiceImpl ) CheckProxyExists ( ctx context . Context , host string , port int , username , password string ) ( bool , error ) {
return s . proxyRepo . ExistsByHostPortAuth ( ctx , host , port , username , password )
}
// Redeem code management implementations
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) ListRedeemCodes ( ctx context . Context , page , pageSize int , codeType , status , search string ) ( [ ] RedeemCode , int64 , error ) {
2025-12-19 21:26:19 +08:00
params := pagination . PaginationParams { Page : page , PageSize : pageSize }
2025-12-18 13:50:39 +08:00
codes , result , err := s . redeemCodeRepo . ListWithFilters ( ctx , params , codeType , status , search )
if err != nil {
return nil , 0 , err
}
return codes , result . Total , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GetRedeemCode ( ctx context . Context , id int64 ) ( * RedeemCode , error ) {
2025-12-18 13:50:39 +08:00
return s . redeemCodeRepo . GetByID ( ctx , id )
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) GenerateRedeemCodes ( ctx context . Context , input * GenerateRedeemCodesInput ) ( [ ] RedeemCode , error ) {
2025-12-18 13:50:39 +08:00
// 如果是订阅类型,验证必须有 GroupID
2025-12-26 15:40:24 +08:00
if input . Type == RedeemTypeSubscription {
2025-12-18 13:50:39 +08:00
if input . GroupID == nil {
return nil , errors . New ( "group_id is required for subscription type" )
}
// 验证分组存在且为订阅类型
group , err := s . groupRepo . GetByID ( ctx , * input . GroupID )
if err != nil {
return nil , fmt . Errorf ( "group not found: %w" , err )
}
if ! group . IsSubscriptionType ( ) {
return nil , errors . New ( "group must be subscription type" )
}
}
2025-12-26 15:40:24 +08:00
codes := make ( [ ] RedeemCode , 0 , input . Count )
2025-12-18 13:50:39 +08:00
for i := 0 ; i < input . Count ; i ++ {
2025-12-26 15:40:24 +08:00
codeValue , err := GenerateRedeemCode ( )
2025-12-20 15:29:52 +08:00
if err != nil {
return nil , err
}
2025-12-26 15:40:24 +08:00
code := RedeemCode {
2025-12-20 15:29:52 +08:00
Code : codeValue ,
2025-12-18 13:50:39 +08:00
Type : input . Type ,
Value : input . Value ,
2025-12-26 15:40:24 +08:00
Status : StatusUnused ,
2025-12-18 13:50:39 +08:00
}
// 订阅类型专用字段
2025-12-26 15:40:24 +08:00
if input . Type == RedeemTypeSubscription {
2025-12-18 13:50:39 +08:00
code . GroupID = input . GroupID
code . ValidityDays = input . ValidityDays
if code . ValidityDays <= 0 {
code . ValidityDays = 30 // 默认30天
}
}
if err := s . redeemCodeRepo . Create ( ctx , & code ) ; err != nil {
return nil , err
}
codes = append ( codes , code )
}
return codes , nil
}
func ( s * adminServiceImpl ) DeleteRedeemCode ( ctx context . Context , id int64 ) error {
return s . redeemCodeRepo . Delete ( ctx , id )
}
func ( s * adminServiceImpl ) BatchDeleteRedeemCodes ( ctx context . Context , ids [ ] int64 ) ( int64 , error ) {
var deleted int64
for _ , id := range ids {
if err := s . redeemCodeRepo . Delete ( ctx , id ) ; err == nil {
deleted ++
}
}
return deleted , nil
}
2025-12-26 15:40:24 +08:00
func ( s * adminServiceImpl ) ExpireRedeemCode ( ctx context . Context , id int64 ) ( * RedeemCode , error ) {
2025-12-18 13:50:39 +08:00
code , err := s . redeemCodeRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
2025-12-26 15:40:24 +08:00
code . Status = StatusExpired
2025-12-18 13:50:39 +08:00
if err := s . redeemCodeRepo . Update ( ctx , code ) ; err != nil {
return nil , err
}
return code , nil
}
func ( s * adminServiceImpl ) TestProxy ( ctx context . Context , id int64 ) ( * ProxyTestResult , error ) {
proxy , err := s . proxyRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
proxyURL := proxy . URL ( )
2025-12-20 11:56:11 +08:00
exitInfo , latencyMs , err := s . proxyProber . ProbeProxy ( ctx , proxyURL )
2025-12-18 13:50:39 +08:00
if err != nil {
2026-01-14 19:45:29 +08:00
s . saveProxyLatency ( ctx , id , & ProxyLatencyInfo {
Success : false ,
Message : err . Error ( ) ,
UpdatedAt : time . Now ( ) ,
} )
2025-12-18 13:50:39 +08:00
return & ProxyTestResult {
Success : false ,
2025-12-20 11:56:11 +08:00
Message : err . Error ( ) ,
2025-12-18 13:50:39 +08:00
} , nil
}
2026-01-14 19:45:29 +08:00
latency := latencyMs
s . saveProxyLatency ( ctx , id , & ProxyLatencyInfo {
2026-01-15 15:15:20 +08:00
Success : true ,
LatencyMs : & latency ,
Message : "Proxy is accessible" ,
IPAddress : exitInfo . IP ,
Country : exitInfo . Country ,
CountryCode : exitInfo . CountryCode ,
Region : exitInfo . Region ,
City : exitInfo . City ,
UpdatedAt : time . Now ( ) ,
2026-01-14 19:45:29 +08:00
} )
2025-12-18 13:50:39 +08:00
return & ProxyTestResult {
2026-01-15 15:15:20 +08:00
Success : true ,
Message : "Proxy is accessible" ,
LatencyMs : latencyMs ,
IPAddress : exitInfo . IP ,
City : exitInfo . City ,
Region : exitInfo . Region ,
Country : exitInfo . Country ,
CountryCode : exitInfo . CountryCode ,
2025-12-18 13:50:39 +08:00
} , nil
}
2026-01-03 06:34:00 -08:00
2026-02-19 21:18:35 +08:00
func ( s * adminServiceImpl ) CheckProxyQuality ( ctx context . Context , id int64 ) ( * ProxyQualityCheckResult , error ) {
proxy , err := s . proxyRepo . GetByID ( ctx , id )
if err != nil {
return nil , err
}
result := & ProxyQualityCheckResult {
ProxyID : id ,
Score : 100 ,
Grade : "A" ,
CheckedAt : time . Now ( ) . Unix ( ) ,
Items : make ( [ ] ProxyQualityCheckItem , 0 , len ( proxyQualityTargets ) + 1 ) ,
}
proxyURL := proxy . URL ( )
if s . proxyProber == nil {
result . Items = append ( result . Items , ProxyQualityCheckItem {
Target : "base_connectivity" ,
Status : "fail" ,
Message : "代理探测服务未配置" ,
} )
result . FailedCount ++
finalizeProxyQualityResult ( result )
2026-02-20 12:13:04 +08:00
s . saveProxyQualitySnapshot ( ctx , id , result , nil )
2026-02-19 21:18:35 +08:00
return result , nil
}
exitInfo , latencyMs , err := s . proxyProber . ProbeProxy ( ctx , proxyURL )
if err != nil {
result . Items = append ( result . Items , ProxyQualityCheckItem {
Target : "base_connectivity" ,
Status : "fail" ,
LatencyMs : latencyMs ,
Message : err . Error ( ) ,
} )
result . FailedCount ++
finalizeProxyQualityResult ( result )
2026-02-20 12:13:04 +08:00
s . saveProxyQualitySnapshot ( ctx , id , result , nil )
2026-02-19 21:18:35 +08:00
return result , nil
}
result . ExitIP = exitInfo . IP
result . Country = exitInfo . Country
result . CountryCode = exitInfo . CountryCode
result . BaseLatencyMs = latencyMs
result . Items = append ( result . Items , ProxyQualityCheckItem {
Target : "base_connectivity" ,
Status : "pass" ,
LatencyMs : latencyMs ,
Message : "代理出口连通正常" ,
} )
result . PassedCount ++
client , err := httpclient . GetClient ( httpclient . Options {
ProxyURL : proxyURL ,
Timeout : proxyQualityRequestTimeout ,
ResponseHeaderTimeout : proxyQualityResponseHeaderTimeout ,
} )
if err != nil {
result . Items = append ( result . Items , ProxyQualityCheckItem {
Target : "http_client" ,
Status : "fail" ,
Message : fmt . Sprintf ( "创建检测客户端失败: %v" , err ) ,
} )
result . FailedCount ++
finalizeProxyQualityResult ( result )
2026-02-20 12:13:04 +08:00
s . saveProxyQualitySnapshot ( ctx , id , result , exitInfo )
2026-02-19 21:18:35 +08:00
return result , nil
}
for _ , target := range proxyQualityTargets {
item := runProxyQualityTarget ( ctx , client , target )
result . Items = append ( result . Items , item )
switch item . Status {
case "pass" :
result . PassedCount ++
case "warn" :
result . WarnCount ++
case "challenge" :
result . ChallengeCount ++
default :
result . FailedCount ++
}
}
finalizeProxyQualityResult ( result )
2026-02-20 12:13:04 +08:00
s . saveProxyQualitySnapshot ( ctx , id , result , exitInfo )
2026-02-19 21:18:35 +08:00
return result , nil
}
func runProxyQualityTarget ( ctx context . Context , client * http . Client , target proxyQualityTarget ) ProxyQualityCheckItem {
item := ProxyQualityCheckItem {
Target : target . Target ,
}
req , err := http . NewRequestWithContext ( ctx , target . Method , target . URL , nil )
if err != nil {
item . Status = "fail"
item . Message = fmt . Sprintf ( "构建请求失败: %v" , err )
return item
}
req . Header . Set ( "Accept" , "application/json,text/html,*/*" )
req . Header . Set ( "User-Agent" , proxyQualityClientUserAgent )
start := time . Now ( )
resp , err := client . Do ( req )
if err != nil {
item . Status = "fail"
item . LatencyMs = time . Since ( start ) . Milliseconds ( )
item . Message = fmt . Sprintf ( "请求失败: %v" , err )
return item
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
item . LatencyMs = time . Since ( start ) . Milliseconds ( )
item . HTTPStatus = resp . StatusCode
body , readErr := io . ReadAll ( io . LimitReader ( resp . Body , proxyQualityMaxBodyBytes + 1 ) )
if readErr != nil {
item . Status = "fail"
item . Message = fmt . Sprintf ( "读取响应失败: %v" , readErr )
return item
}
if int64 ( len ( body ) ) > proxyQualityMaxBodyBytes {
body = body [ : proxyQualityMaxBodyBytes ]
}
if target . Target == "sora" && soraerror . IsCloudflareChallengeResponse ( resp . StatusCode , resp . Header , body ) {
item . Status = "challenge"
item . CFRay = soraerror . ExtractCloudflareRayID ( resp . Header , body )
item . Message = "Sora 命中 Cloudflare challenge"
return item
}
if _ , ok := target . AllowedStatuses [ resp . StatusCode ] ; ok {
2026-02-20 14:42:07 +08:00
if resp . StatusCode >= http . StatusOK && resp . StatusCode < http . StatusMultipleChoices {
item . Status = "pass"
item . Message = fmt . Sprintf ( "HTTP %d" , resp . StatusCode )
} else {
item . Status = "warn"
item . Message = fmt . Sprintf ( "HTTP %d( 目标可达, 但鉴权或方法受限) " , resp . StatusCode )
}
2026-02-19 21:18:35 +08:00
return item
}
if resp . StatusCode == http . StatusTooManyRequests {
item . Status = "warn"
item . Message = "目标返回 429, 可能存在频控"
return item
}
item . Status = "fail"
item . Message = fmt . Sprintf ( "非预期状态码: %d" , resp . StatusCode )
return item
}
func finalizeProxyQualityResult ( result * ProxyQualityCheckResult ) {
if result == nil {
return
}
score := 100 - result . WarnCount * 10 - result . FailedCount * 22 - result . ChallengeCount * 30
if score < 0 {
score = 0
}
result . Score = score
result . Grade = proxyQualityGrade ( score )
result . Summary = fmt . Sprintf (
"通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项" ,
result . PassedCount ,
result . WarnCount ,
result . FailedCount ,
result . ChallengeCount ,
)
}
func proxyQualityGrade ( score int ) string {
switch {
case score >= 90 :
return "A"
case score >= 75 :
return "B"
case score >= 60 :
return "C"
case score >= 40 :
return "D"
default :
return "F"
}
}
2026-02-20 12:13:04 +08:00
func proxyQualityOverallStatus ( result * ProxyQualityCheckResult ) string {
if result == nil {
return ""
}
if result . ChallengeCount > 0 {
return "challenge"
}
if result . FailedCount > 0 {
return "failed"
}
if result . WarnCount > 0 {
return "warn"
}
if result . PassedCount > 0 {
return "healthy"
}
return "failed"
}
func proxyQualityFirstCFRay ( result * ProxyQualityCheckResult ) string {
if result == nil {
return ""
}
for _ , item := range result . Items {
if item . CFRay != "" {
return item . CFRay
}
}
return ""
}
func proxyQualityBaseConnectivityPass ( result * ProxyQualityCheckResult ) bool {
if result == nil {
return false
}
for _ , item := range result . Items {
if item . Target == "base_connectivity" {
return item . Status == "pass"
}
}
return false
}
func ( s * adminServiceImpl ) saveProxyQualitySnapshot ( ctx context . Context , proxyID int64 , result * ProxyQualityCheckResult , exitInfo * ProxyExitInfo ) {
if result == nil {
return
}
score := result . Score
checkedAt := result . CheckedAt
info := & ProxyLatencyInfo {
Success : proxyQualityBaseConnectivityPass ( result ) ,
Message : result . Summary ,
QualityStatus : proxyQualityOverallStatus ( result ) ,
QualityScore : & score ,
QualityGrade : result . Grade ,
QualitySummary : result . Summary ,
QualityCheckedAt : & checkedAt ,
QualityCFRay : proxyQualityFirstCFRay ( result ) ,
UpdatedAt : time . Now ( ) ,
}
if result . BaseLatencyMs > 0 {
latency := result . BaseLatencyMs
info . LatencyMs = & latency
}
if exitInfo != nil {
info . IPAddress = exitInfo . IP
info . Country = exitInfo . Country
info . CountryCode = exitInfo . CountryCode
info . Region = exitInfo . Region
info . City = exitInfo . City
}
s . saveProxyLatency ( ctx , proxyID , info )
}
2026-01-14 19:45:29 +08:00
func ( s * adminServiceImpl ) probeProxyLatency ( ctx context . Context , proxy * Proxy ) {
if s . proxyProber == nil || proxy == nil {
return
}
2026-01-15 15:15:20 +08:00
exitInfo , latencyMs , err := s . proxyProber . ProbeProxy ( ctx , proxy . URL ( ) )
2026-01-14 19:45:29 +08:00
if err != nil {
s . saveProxyLatency ( ctx , proxy . ID , & ProxyLatencyInfo {
Success : false ,
Message : err . Error ( ) ,
UpdatedAt : time . Now ( ) ,
} )
return
}
latency := latencyMs
s . saveProxyLatency ( ctx , proxy . ID , & ProxyLatencyInfo {
2026-01-15 15:15:20 +08:00
Success : true ,
LatencyMs : & latency ,
Message : "Proxy is accessible" ,
IPAddress : exitInfo . IP ,
Country : exitInfo . Country ,
CountryCode : exitInfo . CountryCode ,
Region : exitInfo . Region ,
City : exitInfo . City ,
UpdatedAt : time . Now ( ) ,
2026-01-14 19:45:29 +08:00
} )
}
2026-01-03 06:34:00 -08:00
// checkMixedChannelRisk 检查分组中是否存在混合渠道( Antigravity + Anthropic)
// 如果存在混合,返回错误提示用户确认
func ( s * adminServiceImpl ) checkMixedChannelRisk ( ctx context . Context , currentAccountID int64 , currentAccountPlatform string , groupIDs [ ] int64 ) error {
// 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段)
currentPlatform := getAccountPlatform ( currentAccountPlatform )
if currentPlatform == "" {
// 不是 Antigravity 或 Anthropic, 无需检查
return nil
}
// 检查每个分组中的其他账号
for _ , groupID := range groupIDs {
accounts , err := s . accountRepo . ListByGroup ( ctx , groupID )
if err != nil {
return fmt . Errorf ( "get accounts in group %d: %w" , groupID , err )
}
// 检查是否存在不同渠道的账号
for _ , account := range accounts {
if currentAccountID > 0 && account . ID == currentAccountID {
continue // 跳过当前账号
}
otherPlatform := getAccountPlatform ( account . Platform )
if otherPlatform == "" {
continue // 不是 Antigravity 或 Anthropic, 跳过
}
// 检测混合渠道
if currentPlatform != otherPlatform {
group , _ := s . groupRepo . GetByID ( ctx , groupID )
groupName := fmt . Sprintf ( "Group %d" , groupID )
if group != nil {
groupName = group . Name
}
return & MixedChannelError {
GroupID : groupID ,
GroupName : groupName ,
CurrentPlatform : currentPlatform ,
OtherPlatform : otherPlatform ,
}
}
}
}
return nil
}
2026-02-28 15:01:20 +08:00
func ( s * adminServiceImpl ) validateGroupIDsExist ( ctx context . Context , groupIDs [ ] int64 ) error {
if len ( groupIDs ) == 0 {
return nil
}
if s . groupRepo == nil {
return errors . New ( "group repository not configured" )
}
if batchReader , ok := s . groupRepo . ( groupExistenceBatchReader ) ; ok {
existsByID , err := batchReader . ExistsByIDs ( ctx , groupIDs )
if err != nil {
return fmt . Errorf ( "check groups exists: %w" , err )
}
for _ , groupID := range groupIDs {
if groupID <= 0 || ! existsByID [ groupID ] {
return fmt . Errorf ( "get group: %w" , ErrGroupNotFound )
}
}
return nil
}
for _ , groupID := range groupIDs {
if _ , err := s . groupRepo . GetByID ( ctx , groupID ) ; err != nil {
return fmt . Errorf ( "get group: %w" , err )
}
}
return nil
}
2026-02-24 17:11:14 +08:00
// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
func ( s * adminServiceImpl ) CheckMixedChannelRisk ( ctx context . Context , currentAccountID int64 , currentAccountPlatform string , groupIDs [ ] int64 ) error {
return s . checkMixedChannelRisk ( ctx , currentAccountID , currentAccountPlatform , groupIDs )
}
2026-01-14 19:45:29 +08:00
func ( s * adminServiceImpl ) attachProxyLatency ( ctx context . Context , proxies [ ] ProxyWithAccountCount ) {
if s . proxyLatencyCache == nil || len ( proxies ) == 0 {
return
}
ids := make ( [ ] int64 , 0 , len ( proxies ) )
for i := range proxies {
ids = append ( ids , proxies [ i ] . ID )
}
latencies , err := s . proxyLatencyCache . GetProxyLatencies ( ctx , ids )
if err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "Warning: load proxy latency cache failed: %v" , err )
2026-01-14 19:45:29 +08:00
return
}
for i := range proxies {
info := latencies [ proxies [ i ] . ID ]
if info == nil {
continue
}
if info . Success {
proxies [ i ] . LatencyStatus = "success"
proxies [ i ] . LatencyMs = info . LatencyMs
} else {
proxies [ i ] . LatencyStatus = "failed"
}
proxies [ i ] . LatencyMessage = info . Message
2026-01-15 15:15:20 +08:00
proxies [ i ] . IPAddress = info . IPAddress
proxies [ i ] . Country = info . Country
proxies [ i ] . CountryCode = info . CountryCode
proxies [ i ] . Region = info . Region
proxies [ i ] . City = info . City
2026-02-20 12:13:04 +08:00
proxies [ i ] . QualityStatus = info . QualityStatus
proxies [ i ] . QualityScore = info . QualityScore
proxies [ i ] . QualityGrade = info . QualityGrade
proxies [ i ] . QualitySummary = info . QualitySummary
proxies [ i ] . QualityChecked = info . QualityCheckedAt
2026-01-14 19:45:29 +08:00
}
}
func ( s * adminServiceImpl ) saveProxyLatency ( ctx context . Context , proxyID int64 , info * ProxyLatencyInfo ) {
if s . proxyLatencyCache == nil || info == nil {
return
}
2026-02-20 12:13:04 +08:00
merged := * info
if latencies , err := s . proxyLatencyCache . GetProxyLatencies ( ctx , [ ] int64 { proxyID } ) ; err == nil {
if existing := latencies [ proxyID ] ; existing != nil {
if merged . QualityCheckedAt == nil &&
merged . QualityScore == nil &&
merged . QualityGrade == "" &&
merged . QualityStatus == "" &&
merged . QualitySummary == "" &&
merged . QualityCFRay == "" {
merged . QualityStatus = existing . QualityStatus
merged . QualityScore = existing . QualityScore
merged . QualityGrade = existing . QualityGrade
merged . QualitySummary = existing . QualitySummary
merged . QualityCheckedAt = existing . QualityCheckedAt
merged . QualityCFRay = existing . QualityCFRay
}
}
}
if err := s . proxyLatencyCache . SetProxyLatency ( ctx , proxyID , & merged ) ; err != nil {
2026-02-12 19:01:09 +08:00
logger . LegacyPrintf ( "service.admin" , "Warning: store proxy latency cache failed: %v" , err )
2026-01-14 19:45:29 +08:00
}
}
2026-01-03 06:34:00 -08:00
// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
func getAccountPlatform ( accountPlatform string ) string {
switch strings . ToLower ( strings . TrimSpace ( accountPlatform ) ) {
case PlatformAntigravity :
return "Antigravity"
case PlatformAnthropic , "claude" :
return "Anthropic"
default :
return ""
}
}
// MixedChannelError 混合渠道错误
type MixedChannelError struct {
GroupID int64
GroupName string
CurrentPlatform string
OtherPlatform string
}
func ( e * MixedChannelError ) Error ( ) string {
2026-03-01 14:39:07 +08:00
return fmt . Sprintf ( "mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages." ,
2026-01-03 06:34:00 -08:00
e . GroupName , e . CurrentPlatform , e . OtherPlatform )
}
2026-03-05 20:54:37 +08:00
func ( s * adminServiceImpl ) ResetAccountQuota ( ctx context . Context , id int64 ) error {
return s . accountRepo . ResetQuotaUsed ( ctx , id )
}
2026-03-12 19:45:13 +08:00
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
// 未设置则调用 disableOpenAITraining 并持久化到 Extra, 返回设置的 mode 值。
func ( s * adminServiceImpl ) EnsureOpenAIPrivacy ( ctx context . Context , account * Account ) string {
if account . Platform != PlatformOpenAI || account . Type != AccountTypeOAuth {
return ""
}
if s . privacyClientFactory == nil {
return ""
}
if account . Extra != nil {
if _ , ok := account . Extra [ "privacy_mode" ] ; ok {
return ""
}
}
token , _ := account . Credentials [ "access_token" ] . ( string )
if token == "" {
return ""
}
var proxyURL string
if account . ProxyID != nil {
if p , err := s . proxyRepo . GetByID ( ctx , * account . ProxyID ) ; err == nil && p != nil {
proxyURL = p . URL ( )
}
}
mode := disableOpenAITraining ( ctx , s . privacyClientFactory , token , proxyURL )
if mode == "" {
return ""
}
_ = s . accountRepo . UpdateExtra ( ctx , account . ID , map [ string ] any { "privacy_mode" : mode } )
return mode
}