Files
sub2api/backend/internal/service/admin_account.go
T

1701 lines
60 KiB
Go
Raw Normal View History

package service
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"reflect"
"strconv"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
// Account management implementations
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode)
if err != nil {
return nil, 0, err
}
return accounts, result.Total, nil
}
func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) {
if s == nil || s.accountRepo == nil {
return nil, nil
}
return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode)
}
func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) {
if s == nil || s.accountRepo == nil {
return nil, nil
}
if groupID != nil {
return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
}
return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI)
}
func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) {
return s.accountRepo.GetByID(ctx, id)
}
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
}
const maxAccountNameRunes = 100
const duplicateAccountOperationIDExtraKey = "duplicate_operation_id"
func duplicateAccountName(sourceName string) string {
const suffix = " (Copy)"
nameRunes := []rune(strings.TrimSpace(sourceName))
maxBaseRunes := maxAccountNameRunes - len([]rune(suffix))
if len(nameRunes) > maxBaseRunes {
nameRunes = nameRunes[:maxBaseRunes]
}
return string(nameRunes) + suffix
}
func cloneAccountJSONMap(value map[string]any) (map[string]any, error) {
if value == nil {
return nil, nil
}
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
cloned := make(map[string]any, len(value))
if err := json.Unmarshal(payload, &cloned); err != nil {
return nil, err
}
return cloned, nil
}
var duplicateAccountDiscardedExtraKeys = map[string]struct{}{
// A retry identity belongs to the operation that created one copy, not to later copies.
duplicateAccountOperationIDExtraKey: {},
// External sync identity belongs to one local account only.
"crs_account_id": {},
"crs_kind": {},
"crs_synced_at": {},
// Local quota usage and derived window timestamps must start fresh.
"quota_used": {},
"quota_daily_used": {},
"quota_weekly_used": {},
"quota_daily_start": {},
"quota_weekly_start": {},
"quota_daily_reset_at": {},
"quota_weekly_reset_at": {},
// Provider observations, capability probes, and transient scheduling state.
"model_rate_limits": {},
"session_window_utilization": {},
"passive_usage_7d_utilization": {},
"passive_usage_7d_reset": {},
"passive_usage_7d_oi_utilization": {},
"passive_usage_7d_oi_reset": {},
"passive_usage_sampled_at": {},
"grok_usage_snapshot": {},
"grok_billing_snapshot": {},
"openai_responses_supported": {},
"openai_compact_supported": {},
"openai_compact_checked_at": {},
"openai_compact_last_status": {},
"openai_compact_last_error": {},
"antigravity_credits_overages": {},
"antigravity_force_token_refresh": {},
"antigravity_force_token_refresh_at": {},
"antigravity_force_token_refresh_reason": {},
"drive_storage_limit": {},
"drive_storage_usage": {},
"drive_tier_updated_at": {},
// Codex fingerprint convergence uses a per-account random seed, never copied from another account.
codexFingerprintSeedExtraKey: {},
"codex_primary_used_percent": {},
"codex_primary_reset_after_seconds": {},
"codex_primary_window_minutes": {},
"codex_secondary_used_percent": {},
"codex_secondary_reset_after_seconds": {},
"codex_secondary_window_minutes": {},
"codex_primary_over_secondary_percent": {},
"codex_usage_updated_at": {},
"codex_5h_used_percent": {},
"codex_5h_reset_after_seconds": {},
"codex_5h_window_minutes": {},
"codex_5h_reset_at": {},
"codex_7d_used_percent": {},
"codex_7d_reset_after_seconds": {},
"codex_7d_window_minutes": {},
"codex_7d_reset_at": {},
}
func duplicateAccountExtra(value map[string]any) (map[string]any, error) {
cloned, err := cloneAccountJSONMap(value)
if err != nil {
return nil, err
}
for key := range duplicateAccountDiscardedExtraKeys {
delete(cloned, key)
}
return cloned, nil
}
func canDuplicateAccountType(accountType string) bool {
switch accountType {
case AccountTypeAPIKey, AccountTypeUpstream, AccountTypeBedrock, AccountTypeServiceAccount:
return true
default:
return false
}
}
func duplicateAccountGroups(source *Account) ([]AccountGroup, []int64) {
if len(source.AccountGroups) > 0 {
groups := make([]AccountGroup, 0, len(source.AccountGroups))
groupIDs := make([]int64, 0, len(source.AccountGroups))
for _, sourceGroup := range source.AccountGroups {
groups = append(groups, AccountGroup{GroupID: sourceGroup.GroupID, Priority: sourceGroup.Priority})
groupIDs = append(groupIDs, sourceGroup.GroupID)
}
return groups, groupIDs
}
groups := make([]AccountGroup, 0, len(source.GroupIDs))
groupIDs := append([]int64(nil), source.GroupIDs...)
for i, groupID := range groupIDs {
groups = append(groups, AccountGroup{GroupID: groupID, Priority: i + 1})
}
return groups, groupIDs
}
func duplicateAccountOperationID(sourceID int64, actorScope, operationKey string) string {
operationKey = strings.TrimSpace(operationKey)
if operationKey == "" {
return ""
}
actorScope = strings.TrimSpace(actorScope)
if actorScope == "" {
actorScope = "admin:0"
}
payload := "admin.accounts.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
digest := sha256.Sum256([]byte(payload))
return fmt.Sprintf("%x", digest)
}
func (s *adminServiceImpl) findDuplicateByOperationID(ctx context.Context, operationID string) (*Account, error) {
if operationID == "" {
return nil, nil
}
accounts, err := s.accountRepo.FindByExtraField(ctx, duplicateAccountOperationIDExtraKey, operationID)
if err != nil {
return nil, fmt.Errorf("find duplicate account operation: %w", err)
}
if len(accounts) == 0 {
return nil, nil
}
account := accounts[0]
return &account, nil
}
// RecoverDuplicateAccount performs a read-only lookup for an already committed duplicate.
// It is used when the idempotency coordinator cannot confirm whether response persistence
// succeeded, and deliberately never repeats the create side effect.
func (s *adminServiceImpl) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
return s.findDuplicateByOperationID(ctx, duplicateAccountOperationID(id, actorScope, operationKey))
}
func cloneAccountValuePointer[T any](value *T) *T {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
// DuplicateAccount creates a paused account from source configuration without carrying first-class
// runtime state. Credentials and extra configuration are deep-copied so normalization of the new
// account cannot mutate the in-memory source. Linked credential shadows are excluded because they
// intentionally do not own credentials and must be created through CreateShadow.
func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
operationID := duplicateAccountOperationID(id, actorScope, operationKey)
existing, err := s.RecoverDuplicateAccount(ctx, id, actorScope, operationKey)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
source, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if source.IsCredentialShadow() {
return nil, infraerrors.BadRequest(
"ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED",
"linked credential shadow accounts cannot be duplicated; duplicate the parent account instead",
)
}
if !canDuplicateAccountType(source.Type) {
return nil, infraerrors.BadRequest(
"ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED",
"accounts with rotating or unsupported credential types cannot be duplicated",
)
}
credentials, err := cloneAccountJSONMap(source.Credentials)
if err != nil {
return nil, fmt.Errorf("clone account credentials: %w", err)
}
extra, err := duplicateAccountExtra(source.Extra)
if err != nil {
return nil, fmt.Errorf("clone account extra configuration: %w", err)
}
if operationID != "" {
if extra == nil {
extra = make(map[string]any, 1)
}
extra[duplicateAccountOperationIDExtraKey] = operationID
}
var expiresAt *int64
if source.ExpiresAt != nil {
unix := source.ExpiresAt.Unix()
expiresAt = &unix
}
autoPauseOnExpired := source.AutoPauseOnExpired
groups, groupIDs := duplicateAccountGroups(source)
proxyID := source.ProxyID
if source.ProxyFallbackOriginID != nil {
// Proxy fallback is transient runtime state; duplicate the configured origin.
proxyID = source.ProxyFallbackOriginID
}
input := &CreateAccountInput{
Name: duplicateAccountName(source.Name),
Notes: cloneAccountValuePointer(source.Notes),
Platform: source.Platform,
Type: source.Type,
Credentials: credentials,
Extra: extra,
ProxyID: cloneAccountValuePointer(proxyID),
Concurrency: source.Concurrency,
Priority: source.Priority,
RateMultiplier: cloneAccountValuePointer(source.RateMultiplier),
LoadFactor: cloneAccountValuePointer(source.LoadFactor),
GroupIDs: groupIDs,
ExpiresAt: expiresAt,
AutoPauseOnExpired: &autoPauseOnExpired,
SkipDefaultGroupBind: true,
SkipMixedChannelCheck: true,
}
accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
if err != nil {
return nil, fmt.Errorf("normalize duplicate account extra: %w", err)
}
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
duplicate, err := buildAccountForCreate(input, accountExtra)
if err != nil {
return nil, err
}
// A copied credential must be reviewed before it can share live traffic with its source.
duplicate.Schedulable = false
if s.accountDuplicateRepo == nil {
return nil, errors.New("account duplicate repository is not configured")
}
if err := s.accountDuplicateRepo.CreateWithAccountGroups(ctx, duplicate, groups); err != nil {
return nil, fmt.Errorf("create duplicate account: %w", err)
}
for i := range groups {
groups[i].AccountID = duplicate.ID
}
duplicate.AccountGroups = groups
duplicate.GroupIDs = groupIDs
return duplicate, nil
}
func normalizeAccountConcurrency(platform, accountType string, concurrency int) int {
if platform == PlatformGrok && accountType == AccountTypeOAuth {
if concurrency <= 0 {
return 1
}
}
return concurrency
}
// ValidateOpenAILongContextBillingExtra validates the OpenAI account billing flag when present.
func ValidateOpenAILongContextBillingExtra(platform string, extra map[string]any) error {
if platform != PlatformOpenAI {
return nil
}
raw, exists := extra[openAILongContextBillingEnabledKey]
if !exists {
return nil
}
if _, ok := raw.(bool); !ok {
return infraerrors.BadRequest(
"OPENAI_LONG_CONTEXT_BILLING_INVALID",
"openai_long_context_billing_enabled must be a boolean",
)
}
return nil
}
func normalizeOpenAILongContextBillingExtra(platform string, extra map[string]any) (map[string]any, error) {
if platform != PlatformOpenAI {
return extra, nil
}
if err := ValidateOpenAILongContextBillingExtra(platform, extra); err != nil {
return nil, err
}
normalized := maps.Clone(extra)
if normalized == nil {
normalized = make(map[string]any, 1)
}
_, exists := normalized[openAILongContextBillingEnabledKey]
if !exists {
normalized[openAILongContextBillingEnabledKey] = false
}
return normalized, nil
}
func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *UpdateAccountInput) (map[string]any, error) {
normalized, err := normalizeOpenAILongContextBillingExtra(account.Platform, input.Extra)
if err != nil || account.Platform != PlatformOpenAI {
return normalized, err
}
_, provided := input.Extra[openAILongContextBillingEnabledKey]
current, hasCurrent := account.Extra[openAILongContextBillingEnabledKey].(bool)
if !provided {
if hasCurrent {
normalized[openAILongContextBillingEnabledKey] = current
}
}
return normalized, nil
}
// Grok media eligibility helpers live in account_grok_media_eligibility.go.
func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
// Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey)
delete(accountExtra, UpstreamBillingProbeExtraKey)
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
account := &Account{
Name: input.Name,
Notes: normalizeAccountNotes(input.Notes),
Platform: input.Platform,
Type: input.Type,
Credentials: input.Credentials,
Extra: accountExtra,
ProxyID: input.ProxyID,
Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency),
Priority: input.Priority,
Status: StatusActive,
Schedulable: true,
}
if input.ProbeEnabled != nil && *input.ProbeEnabled {
if !isUpstreamBillingProbeAccount(account) {
return nil, ErrUpstreamBillingProbeAccountInvalid
}
if account.Extra == nil {
account.Extra = make(map[string]any)
}
account.Extra[UpstreamBillingProbeEnabledExtraKey] = true
}
// 预计算固定时间重置的下次重置时间
if account.Extra != nil {
if err := ValidateQuotaResetConfig(account.Extra); err != nil {
return nil, err
}
ComputeQuotaResetAt(account.Extra)
NormalizeFixedQuotaWindows(account.Extra)
}
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
}
if input.RateMultiplier != nil {
if *input.RateMultiplier < 0 {
return nil, errors.New("rate_multiplier must be >= 0")
}
account.RateMultiplier = input.RateMultiplier
}
if input.LoadFactor != nil && *input.LoadFactor > 0 {
if *input.LoadFactor > 10000 {
return nil, errors.New("load_factor must be <= 10000")
}
account.LoadFactor = input.LoadFactor
}
return account, nil
}
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra)
if err != nil {
return nil, err
}
accountExtra, err = normalizeGrokMediaEligibilityExtra(input.Platform, accountExtra)
if err != nil {
return nil, err
}
// 绑定分组
groupIDs := input.GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
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
}
}
}
}
// 检查混合渠道风险(除非用户已确认)
if len(groupIDs) > 0 && !input.SkipMixedChannelCheck {
if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil {
return nil, err
}
}
// 校验并规范化请求头覆写配置(header 名小写化、格式检查)
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
// Never persist ephemeral SSO/password secrets after OAuth conversion.
input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials)
account, err := buildAccountForCreate(input, accountExtra)
if err != nil {
return nil, err
}
if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err
}
// 绑定分组
if len(groupIDs) > 0 {
if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
return nil, err
}
}
// OAuth 账号:创建后异步设置隐私。
// 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。
if account.Type == AccountTypeOAuth {
switch account.Platform {
case PlatformOpenAI:
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r)
}
}()
s.EnsureOpenAIPrivacy(context.Background(), account)
}()
case PlatformAntigravity:
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r)
}
}()
s.EnsureAntigravityPrivacy(context.Background(), account)
}()
}
}
return account, nil
}
func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
var normalizedExtra map[string]any
if input.Extra != nil {
normalizedExtra, err = normalizeOpenAILongContextBillingUpdateExtra(account, input)
if err != nil {
return nil, err
}
normalizedExtra, err = normalizeGrokMediaEligibilityUpdateExtra(account, input, normalizedExtra)
if err != nil {
return nil, err
}
}
previousProbeIdentity := upstreamBillingProbeIdentity(account)
previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
// 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
// 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
if account.IsCredentialShadow() {
// 影子绝不持有凭据(凭据只在母账号)——外审 F5。
if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) {
return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS",
"spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account")
}
// 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT
// header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。
if input.Type != "" && input.Type != account.Type {
return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE",
"spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow")
}
} else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth {
// 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母
// 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount
// 必报错)。须先删影子再改 type。
shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id)
if serr != nil {
return nil, serr
}
if len(shadows) > 0 {
return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE",
"cannot change account type while it has a spark shadow; delete the shadow first")
}
}
wasOveragesEnabled := account.IsOveragesEnabled()
if input.Name != "" {
account.Name = input.Name
}
if input.Type != "" {
account.Type = input.Type
}
if input.Notes != nil {
account.Notes = normalizeAccountNotes(input.Notes)
}
if account.IsCredentialShadow() && input.Credentials != nil {
account.Credentials = sanitizeSparkShadowCredentials(input.Credentials)
} else if len(input.Credentials) > 0 {
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
// 校验并规范化请求头覆写配置(header 名小写化、格式检查)
if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
return nil, err
}
// Strip SSO/password residue that must never sit next to OAuth tokens.
account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials)
}
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
requestedProbeEnabledUpdate := input.ProbeEnabled
requestedRateSyncEnabledUpdate := input.RateSyncEnabled
if input.Extra != nil {
requestedProbeEnabled, hasRequestedProbeEnabled := normalizedExtra[UpstreamBillingProbeEnabledExtraKey]
if hasRequestedProbeEnabled {
enabled, ok := requestedProbeEnabled.(bool)
if !ok {
return nil, infraerrors.BadRequest("INVALID_UPSTREAM_BILLING_PROBE_ENABLED", "upstream_billing_probe_enabled must be a boolean")
}
if requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate != enabled {
return nil, infraerrors.BadRequest("CONFLICTING_UPSTREAM_BILLING_PROBE_ENABLED", "conflicting upstream_billing_probe_enabled values")
}
requestedProbeEnabledUpdate = &enabled
}
delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
delete(normalizedExtra, UpstreamBillingRateSyncEnabledExtraKey)
delete(normalizedExtra, UpstreamBillingProbeExtraKey)
delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
// 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
for _, key := range []string{
"quota_used",
"quota_daily_used",
"quota_daily_start",
"quota_weekly_used",
"quota_weekly_start",
grokBillingExtraKey,
UpstreamBillingProbeEnabledExtraKey,
UpstreamBillingRateSyncEnabledExtraKey,
UpstreamBillingProbeExtraKey,
OllamaCloudUsageSessionExtraKey,
OllamaCloudUsageAutoRefreshExtraKey,
OllamaCloudUsageSnapshotExtraKey,
} {
if v, ok := account.Extra[key]; ok {
normalizedExtra[key] = v
}
}
normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra)
account.Extra = normalizedExtra
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
// 清除 AICredits 限流 key
if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok {
delete(rawLimits, creditsExhaustedKey)
}
}
if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() {
delete(account.Extra, modelRateLimitsKey)
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
}
// 校验并预计算固定时间重置的下次重置时间
if err := ValidateQuotaResetConfig(account.Extra); err != nil {
return nil, err
}
ComputeQuotaResetAt(account.Extra)
NormalizeFixedQuotaWindows(account.Extra)
}
if input.Extra == nil {
account.Extra = prepareCodexFingerprintExtraForUpdate(account, account.Extra)
}
if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate {
if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
return nil, infraerrors.BadRequest(
"UPSTREAM_BILLING_RATE_SYNC_REQUIRES_PROBE",
"upstream billing rate sync requires upstream billing probe",
)
}
enabled := true
requestedProbeEnabledUpdate = &enabled
}
if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
disabled := false
requestedRateSyncEnabledUpdate = &disabled
}
if (requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate) ||
(requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate) {
if !isUpstreamBillingProbeAccount(account) {
return nil, ErrUpstreamBillingProbeAccountInvalid
}
}
if account.Extra == nil && (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) {
account.Extra = make(map[string]any)
}
if requestedProbeEnabledUpdate != nil {
account.Extra[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
}
if requestedRateSyncEnabledUpdate != nil {
account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
}
// 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1;
// 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。
if input.ProxyID != nil && !account.IsCredentialShadow() {
// 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图)
if *input.ProxyID == 0 {
account.ProxyID = nil
} else {
account.ProxyID = input.ProxyID
}
account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID
}
if !reflect.DeepEqual(previousProbeIdentity, upstreamBillingProbeIdentity(account)) && account.Extra != nil {
delete(account.Extra, UpstreamBillingProbeExtraKey)
if !isUpstreamBillingProbeAccount(account) {
delete(account.Extra, UpstreamBillingProbeEnabledExtraKey)
delete(account.Extra, UpstreamBillingRateSyncEnabledExtraKey)
}
}
if account.Extra != nil {
if !IsOllamaCloudUsageAccount(account) {
delete(account.Extra, OllamaCloudUsageSessionExtraKey)
delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
} else if !reflect.DeepEqual(previousOllamaUsageIdentity, ollamaCloudUsageIdentity(account)) {
delete(account.Extra, OllamaCloudUsageSessionExtraKey)
delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
}
}
// 只在指针非 nil 时更新 Concurrency(支持设置为 0
if input.Concurrency != nil {
account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
}
// 只在指针非 nil 时更新 Priority(支持设置为 0)
if input.Priority != nil {
account.Priority = *input.Priority
}
if input.RateMultiplier != nil {
if *input.RateMultiplier < 0 {
return nil, errors.New("rate_multiplier must be >= 0")
}
// 同步开启时倍率归上游所有,手工值活不过下一次成功探测(表现为"改了又自己
// 变回去"),与批量路径一样直接拒绝。判断的是本次请求生效后的状态:上面
// 已把请求携带的两个开关落进 account.Extra,所以"同一请求关闭同步 + 改倍率"
// (用户显式收回所有权)会走到这里时读到 false,正常放行。
if upstreamBillingRateSyncEnabled(account) {
return nil, ErrUpstreamBillingRateSyncConflict
}
account.RateMultiplier = input.RateMultiplier
}
if input.LoadFactor != nil {
if *input.LoadFactor <= 0 {
account.LoadFactor = nil // 0 或负数表示清除
} else if *input.LoadFactor > 10000 {
return nil, errors.New("load_factor must be <= 10000")
} else {
account.LoadFactor = input.LoadFactor
}
}
if input.Status != "" {
account.Status = input.Status
}
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
}
// 先验证分组是否存在(在任何写操作之前)
if input.GroupIDs != nil {
if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
return nil, err
}
// 检查混合渠道风险(除非用户已确认)
if !input.SkipMixedChannelCheck {
if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil {
return nil, err
}
}
}
billingSettingsAppliedAtomically := false
updater := s.accountBillingRepo
if updater == nil {
// Unit tests and narrow internal callers may construct adminServiceImpl
// directly; production wiring requires this capability through
// AdminAccountRepository.
updater, _ = s.accountRepo.(AccountBillingSettingsRepository)
}
if updater != nil {
if err := updater.UpdateWithAccountBillingSettings(
ctx,
account,
requestedProbeEnabledUpdate,
requestedRateSyncEnabledUpdate,
input.RateMultiplier,
); err != nil {
return nil, err
}
billingSettingsAppliedAtomically = true
}
if !billingSettingsAppliedAtomically {
if err := s.accountRepo.Update(ctx, account); err != nil {
return nil, err
}
if (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) &&
isUpstreamBillingProbeAccount(account) {
settings := make(map[string]any, 2)
if requestedProbeEnabledUpdate != nil {
settings[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
}
if requestedRateSyncEnabledUpdate != nil {
settings[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, settings); err != nil {
return nil, err
}
}
}
// 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。
// 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。
if input.ProxyID != nil && !account.IsCredentialShadow() {
if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil {
return nil, err
}
}
// 绑定分组
if input.GroupIDs != nil {
if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil {
return nil, err
}
}
// 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象)
updated, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return updated, nil
}
// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
// (如 model_rate_limits / passive_usage_* 等)。
func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
updates = sanitizedCodexFingerprintExtraUpdates(updates)
delete(updates, UpstreamBillingProbeEnabledExtraKey)
delete(updates, UpstreamBillingRateSyncEnabledExtraKey)
delete(updates, UpstreamBillingProbeExtraKey)
delete(updates, OllamaCloudUsageSessionExtraKey)
delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
delete(updates, OllamaCloudUsageSnapshotExtraKey)
if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return err
}
if err := ValidateOpenAILongContextBillingExtra(account.Platform, updates); err != nil {
return err
}
}
if len(updates) == 0 {
return nil
}
return s.accountRepo.UpdateExtra(ctx, id, updates)
}
// 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) {
// Managed probe/session state may only enter through dedicated typed endpoints.
input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra)
delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey)
delete(input.Extra, UpstreamBillingProbeExtraKey)
delete(input.Extra, OllamaCloudUsageSessionExtraKey)
delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
if len(input.AccountIDs) == 0 && input.Filters != nil {
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
if err != nil {
return nil, err
}
input.AccountIDs = accountIDs
}
result := &BulkUpdateAccountsResult{
SuccessIDs: make([]int64, 0, len(input.AccountIDs)),
FailedIDs: make([]int64, 0, len(input.AccountIDs)),
Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)),
}
if len(input.AccountIDs) == 0 {
return result, nil
}
if input.GroupIDs != nil {
if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
openAISettings, err := normalizeBulkOpenAISettings(input)
if err != nil {
return nil, err
}
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
var cachedTargets []*Account
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil {
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
return nil, err
}
cachedTargets = loaded
}
targetsByID := make(map[int64]*Account, len(cachedTargets))
for _, account := range cachedTargets {
if account != nil {
targetsByID[account.ID] = account
}
}
if openAISettings.any() {
inheritedCount, err := validateBulkOpenAISettingsTargets(input, openAISettings, targetsByID)
if err != nil {
return nil, err
}
result.LongContextInheritedCount = inheritedCount
}
if input.ProbeEnabled != nil {
for _, accountID := range input.AccountIDs {
account, ok := targetsByID[accountID]
if !ok {
return nil, ErrAccountNotFound
}
if !isUpstreamBillingProbeAccount(account) {
return nil, ErrUpstreamBillingProbeAccountInvalid
}
}
}
// 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号
// UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。
if len(input.Credentials) > 0 {
for _, acc := range cachedTargets {
if acc != nil && acc.IsCredentialShadow() {
return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS",
"spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID)
}
}
}
// 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy
// 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站,
// 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。
if input.ProxyID != nil {
for _, acc := range cachedTargets {
if acc != nil && acc.IsCredentialShadow() {
return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED",
"spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID)
}
}
}
// 预加载账号平台信息(混合渠道检查需要)。
platformByID := map[int64]string{}
if needMixedChannelCheck {
for _, account := range cachedTargets {
if account != nil {
platformByID[account.ID] = account.Platform
}
}
}
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
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
}
}
}
if input.RateMultiplier != nil {
if *input.RateMultiplier < 0 {
return nil, errors.New("rate_multiplier must be >= 0")
}
syncEnabledCount := 0
for _, account := range cachedTargets {
if account == nil || account.Extra == nil {
continue
}
enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool)
if enabled {
syncEnabledCount++
}
}
if syncEnabledCount > 0 {
return nil, ErrUpstreamBillingRateSyncBulkConflict.WithMetadata(map[string]string{
"count": strconv.Itoa(syncEnabledCount),
})
}
}
// 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
return nil, err
}
// Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
// only when platform is known Grok — empty platform still strips password/*).
if input.Credentials != nil {
input.Credentials = SanitizeStoredCredentials("", input.Credentials)
}
// Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials,
Extra: input.Extra,
ProbeEnabled: input.ProbeEnabled,
EnsureCodexFingerprintSeed: ShouldEnsureCodexFingerprintSeedForExtraUpdates(input.Extra),
}
if input.ProbeEnabled != nil {
if repoUpdates.Extra == nil {
repoUpdates.Extra = make(map[string]any)
}
repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled
if !*input.ProbeEnabled {
repoUpdates.Extra[UpstreamBillingRateSyncEnabledExtraKey] = false
}
}
if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil {
if repoUpdates.Extra == nil {
repoUpdates.Extra = make(map[string]any)
}
// JSON null makes every reader treat the old snapshot as absent and lets the
// next enabled runner cycle probe the new upstream identity immediately.
repoUpdates.Extra[UpstreamBillingProbeExtraKey] = nil
}
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
}
if input.RateMultiplier != nil {
repoUpdates.RateMultiplier = input.RateMultiplier
}
if input.LoadFactor != nil {
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
}
}
if input.Status != "" {
repoUpdates.Status = &input.Status
}
if input.Schedulable != nil {
repoUpdates.Schedulable = input.Schedulable
}
// Run bulk update for column/jsonb fields first.
if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil {
return nil, err
}
// 将 proxy 变更传播到每个目标账号的 spark 影子账号
if repoUpdates.ProxyID != nil {
var effectiveProxyID *int64
if *repoUpdates.ProxyID != 0 {
effectiveProxyID = repoUpdates.ProxyID
}
for _, accountID := range input.AccountIDs {
if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); 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++
result.FailedIDs = append(result.FailedIDs, accountID)
result.Results = append(result.Results, entry)
continue
}
}
entry.Success = true
result.Success++
result.SuccessIDs = append(result.SuccessIDs, accountID)
result.Results = append(result.Results, entry)
}
return result, nil
}
func updatesUpstreamBillingProbeIdentity(credentials map[string]any) bool {
for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
if _, ok := credentials[key]; ok {
return true
}
}
return false
}
func upstreamBillingProbeIdentity(account *Account) map[string]any {
if account == nil {
return nil
}
identity := map[string]any{"platform": account.Platform, "type": account.Type, "proxy_id": nil}
if account.ProxyID != nil {
identity["proxy_id"] = *account.ProxyID
}
for _, key := range []string{"api_key", "base_url", credKeyHeaderOverrideEnabled, credKeyHeaderOverrides} {
if value, ok := account.Credentials[key]; ok {
identity[key] = value
}
}
return identity
}
func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) {
if filters == nil {
return nil, nil
}
groupID := int64(0)
switch strings.TrimSpace(filters.Group) {
case "":
case "ungrouped":
groupID = AccountListGroupUngrouped
default:
parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid group filter: %w", err)
}
groupID = parsedGroupID
}
const pageSize = 500
page := 1
accountIDs := make([]int64, 0, pageSize)
for {
accounts, total, err := s.ListAccounts(
ctx,
page,
pageSize,
filters.Platform,
filters.Type,
filters.Status,
filters.Search,
groupID,
filters.PrivacyMode,
"",
"",
)
if err != nil {
return nil, err
}
for _, account := range accounts {
accountIDs = append(accountIDs, account.ID)
}
if int64(len(accountIDs)) >= total || len(accounts) == 0 {
return accountIDs, nil
}
page++
}
}
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
// 级联删除 spark 影子账号(先删影子,再删母账号)
shadows, err := s.accountRepo.ListShadowsByParent(ctx, id)
if err != nil {
return fmt.Errorf("list spark shadows for cascade delete: %w", err)
}
for _, shadow := range shadows {
if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil {
return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err)
}
}
if err := s.accountRepo.Delete(ctx, id); err != nil {
return err
}
return nil
}
func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// TODO: Implement refresh logic
return account, nil
}
func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) {
if err := s.accountRepo.ClearError(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil {
return nil, err
}
if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil {
return nil, err
}
if s.runtimeBlocker != nil {
s.runtimeBlocker.ClearAccountSchedulingBlock(id)
}
return s.accountRepo.GetByID(ctx, id)
}
func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error {
return s.accountRepo.SetError(ctx, id, errorMsg)
}
func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) {
if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil {
return nil, err
}
updated, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
return updated, nil
}
func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error {
if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil {
return err
}
// 加载回退后的账号以获取实际 ProxyID,再传播到影子账号
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("get account after proxy revert: %w", err)
}
return s.propagateProxyToShadows(ctx, id, account.ProxyID)
}
// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。
// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。
func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) {
// 1. 加载母账号并校验平台/类型
parent, err := s.accountRepo.GetByID(ctx, parentID)
if err != nil {
return nil, fmt.Errorf("get parent account: %w", err)
}
if !parent.IsOpenAIOAuth() {
return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT",
"spark shadow requires an OpenAI OAuth parent account")
}
// G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层,
// 会解析到无凭据的一级影子,进入坏调度/上游失败。
if parent.IsCredentialShadow() {
return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW",
"spark shadow parent must be a real account, not another spark shadow")
}
// 2. 一母一影校验
shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID)
if err != nil {
return nil, fmt.Errorf("check existing spark shadows: %w", err)
}
if len(shadows) > 0 {
return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS",
"parent account already has a spark shadow account")
}
// 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义
// 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。
// 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组
// 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。
groupIDs := opts.GroupIDs
if len(groupIDs) > 0 {
if s.groupRepo != nil {
if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil {
return nil, err
}
}
} else if len(parent.GroupIDs) > 0 {
groupIDs = append([]int64(nil), parent.GroupIDs...)
} else if s.groupRepo != nil {
defaultGroupName := PlatformOpenAI + "-default"
if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil {
for _, g := range groups {
if g.Name == defaultGroupName {
groupIDs = []int64{g.ID}
break
}
}
}
}
// 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。
// name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500
// (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。
name := strings.TrimSpace(opts.Name)
if name == "" {
name = parent.Name + " (Spark)"
}
if runes := []rune(name); len(runes) > 100 {
name = string(runes[:100])
}
// 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。
concurrency := opts.Concurrency
if concurrency <= 0 {
concurrency = parent.Concurrency
}
// 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度
// 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo
// 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。
// 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。
priority := opts.Priority
if priority <= 0 {
priority = parent.Priority
}
shadow := &Account{
Name: name,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()},
ParentAccountID: &parentID,
QuotaDimension: QuotaDimensionSpark,
ProxyID: parent.ProxyID,
Priority: priority,
Concurrency: concurrency,
Schedulable: true,
Extra: map[string]any{
openAILongContextBillingEnabledKey: parent.IsOpenAILongContextBillingEnabled(),
},
}
// 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞
// 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。
if err := s.accountRepo.Create(ctx, shadow); err != nil {
if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 {
return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS",
"parent account already has a spark shadow account")
}
return nil, fmt.Errorf("create spark shadow: %w", err)
}
// 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql,
// 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则
// 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时
// 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。
if len(groupIDs) > 0 {
if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil {
if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil {
slog.Error("spark_shadow_bind_groups_rollback_failed",
"shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr)
}
return nil, fmt.Errorf("bind groups for spark shadow: %w", err)
}
shadow.GroupIDs = groupIDs
}
return shadow, nil
}
// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID.
// It is called synchronously so that proxy changes are immediately consistent;
// accountRepo.Update triggers the scheduler outbox + cache propagation internally.
// Calling this for a non-parent account is a harmless no-op.
func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error {
return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID)
}
// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。
// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留
// 旧 proxy 出现出站漂移(外审第8轮)。
func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error {
shadows, err := repo.ListShadowsByParent(ctx, parentID)
if err != nil {
return fmt.Errorf("list spark shadows for proxy propagation: %w", err)
}
for _, shadow := range shadows {
shadow.ProxyID = proxyID
if err := repo.Update(ctx, shadow); err != nil {
return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err)
}
}
return nil
}
// 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
}
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
}
// 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)
}
// 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 {
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.",
e.GroupName, e.CurrentPlatform, e.OtherPlatform)
}
func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return err
}
// spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护),
// 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。
if account.IsCredentialShadow() {
return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET",
"cannot reset quota for a spark shadow account; manage it on the parent account")
}
return s.accountRepo.ResetQuotaUsed(ctx, id)
}
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode
// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。
func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string {
// 影子账号不持凭据,隐私设置由母账号管理,直接跳过。
if account.IsCredentialShadow() {
return ""
}
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
return ""
}
if s.privacyClientFactory == nil {
return ""
}
if shouldSkipOpenAIPrivacyEnsure(account.Extra) {
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
}
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string {
// 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。
if account.IsCredentialShadow() {
return ""
}
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
return ""
}
if s.privacyClientFactory == nil {
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 ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
if account.Extra == nil {
account.Extra = make(map[string]any)
}
account.Extra["privacy_mode"] = mode
return mode
}
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
// 仅当 privacy_mode 已成功设置("privacy_set")时跳过;
// 未设置或之前失败("privacy_set_failed")均会重试。
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
if account.Extra != nil {
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet {
return existing
}
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
return ""
}
token, _ := account.Credentials["access_token"].(string)
if token == "" {
return ""
}
projectID, _ := account.Credentials["project_id"].(string)
var proxyURL string
if account.ProxyID != nil {
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
proxyURL = p.URL()
}
}
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
if mode == "" {
return ""
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
return mode
}
applyAntigravityPrivacyMode(account, mode)
return mode
}