Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1701 lines
60 KiB
Go
1701 lines
60 KiB
Go
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
|
||
}
|