Files
sub2api/backend/internal/service/grok_oauth_reconciliation.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

346 lines
12 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
const (
defaultGrokOAuthReconcilePageSize = 50
maxGrokOAuthReconcilePageSize = 500
maxGrokOAuthReconcileWindow = 24 * time.Hour
GrokOAuthReconcileReasonMissingRefreshToken = "missing_refresh_token"
GrokOAuthReconcileReasonMissingAccessToken = "missing_access_token"
GrokOAuthReconcileReasonMissingExpiry = "missing_expiry"
GrokOAuthReconcileReasonInvalidExpiry = "invalid_expiry"
GrokOAuthReconcileReasonNearExpiry = "near_expiry"
GrokOAuthReconcileReasonCredentialRejected = "credential_rejected"
GrokOAuthReconcileActionBlock = "block_account"
GrokOAuthReconcileActionRefresh = "refresh_credentials"
GrokOAuthReconcileOutcomePlanned = "planned"
GrokOAuthReconcileOutcomeApplied = "applied"
GrokOAuthReconcileOutcomeSkipped = "skipped"
GrokOAuthReconcileOutcomeFailed = "failed"
GrokOAuthReconcileOutcomePartial = "partial"
)
var (
ErrGrokOAuthReconcileMode = infraerrors.BadRequest(
"GROK_OAUTH_RECONCILE_MODE_INVALID",
"apply requires dry_run=false and apply=true",
)
ErrGrokOAuthReconcileCursor = infraerrors.BadRequest(
"GROK_OAUTH_RECONCILE_CURSOR_INVALID",
"after_id must be non-negative",
)
ErrGrokOAuthReconcileLimit = infraerrors.BadRequest(
"GROK_OAUTH_RECONCILE_LIMIT_INVALID",
"limit is outside the allowed reconciliation page range",
)
ErrGrokOAuthReconcileWindow = infraerrors.BadRequest(
"GROK_OAUTH_RECONCILE_WINDOW_INVALID",
"refresh_window_seconds is outside the allowed range",
)
)
// GrokOAuthReconciler is the narrow admin-facing reconciliation port.
type GrokOAuthReconciler interface {
ReconcileGrokOAuth(ctx context.Context, input GrokOAuthReconcileInput) (*GrokOAuthReconcileResult, error)
}
// GrokOAuthConditionalErrorRepository is the narrow compare-and-set mutation
// used by reconciliation. The repository must only transition an active Grok
// OAuth account when its credential document still exactly matches the state
// observed immediately before the mutation.
type GrokOAuthConditionalErrorRepository interface {
SetGrokOAuthErrorIfCredentialsUnchanged(ctx context.Context, id int64, expectedCredentials map[string]any, errorMsg string) (bool, error)
}
type GrokOAuthReconcileInput struct {
DryRun bool
Apply bool
AfterID int64
Limit int
RefreshWindow time.Duration
}
// GrokOAuthReconcileItem is deliberately metadata-only. Credentials, account
// identity fields, provider response bodies, and raw errors never cross this API.
type GrokOAuthReconcileItem struct {
AccountID int64 `json:"account_id"`
Reason string `json:"reason"`
Action string `json:"action"`
Outcome string `json:"outcome"`
}
type GrokOAuthReconcileResult struct {
DryRun bool `json:"dry_run"`
Scanned int `json:"scanned"`
Actionable int `json:"actionable"`
WouldBlock int `json:"would_block"`
WouldRefresh int `json:"would_refresh"`
Blocked int `json:"blocked"`
Refreshed int `json:"refreshed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Partial int `json:"partial"`
Items []GrokOAuthReconcileItem `json:"items"`
NextAfterID int64 `json:"next_after_id"`
HasMore bool `json:"has_more"`
}
func (s *TokenRefreshService) ReconcileGrokOAuth(ctx context.Context, input GrokOAuthReconcileInput) (*GrokOAuthReconcileResult, error) {
if ctx == nil {
ctx = context.Background()
}
if input.Apply && input.DryRun {
return nil, ErrGrokOAuthReconcileMode
}
if input.AfterID < 0 {
return nil, ErrGrokOAuthReconcileCursor
}
limit := input.Limit
maxPageSize := s.grokOAuthReconcileMaxPageSize()
if limit == 0 {
limit = min(defaultGrokOAuthReconcilePageSize, maxPageSize)
}
if limit < 1 || limit > maxPageSize {
return nil, ErrGrokOAuthReconcileLimit
}
refreshWindow := input.RefreshWindow
if refreshWindow == 0 {
refreshWindow = grokTokenRefreshSkew
}
if refreshWindow < 0 || refreshWindow > maxGrokOAuthReconcileWindow {
return nil, ErrGrokOAuthReconcileWindow
}
if refreshWindow < grokTokenRefreshSkew {
refreshWindow = grokTokenRefreshSkew
}
dryRun := !input.Apply
pager := s.candidatePager
if pager == nil {
pager, _ = s.accountRepo.(OAuthRefreshCandidatePager)
}
if pager == nil {
return nil, errors.New("OAuth refresh candidate pager is not configured")
}
page, err := pager.ListOAuthRefreshCandidatePage(ctx, OAuthRefreshPageOptions{
Platforms: []string{PlatformGrok},
AfterID: input.AfterID,
Limit: limit,
ActiveOnly: true,
// Reconciliation scans OAuth only and intentionally does not require a
// refresh token so structurally invalid rows remain discoverable.
IncludeSetupToken: false,
RequireRefreshToken: false,
})
if err != nil {
return nil, err
}
if page == nil {
return nil, errors.New("OAuth reconciliation repository returned a nil cursor page")
}
accounts := page.Accounts
if !isStrictlyIncreasingAccountPage(accounts, input.AfterID) {
return nil, errors.New("OAuth reconciliation repository returned an invalid cursor page")
}
result := &GrokOAuthReconcileResult{
DryRun: dryRun,
Scanned: len(accounts),
Items: make([]GrokOAuthReconcileItem, 0, len(accounts)),
HasMore: page.HasMore,
}
if result.HasMore {
if page.NextAfterID <= input.AfterID {
return nil, errors.New("OAuth reconciliation repository returned invalid cursor metadata")
}
result.NextAfterID = page.NextAfterID
}
registration, ok := s.grokRegistration()
if !ok {
return nil, errors.New("grok OAuth refresher is not registered")
}
conditionalErrorRepo, supportsConditionalError := s.accountRepo.(GrokOAuthConditionalErrorRepository)
if input.Apply && !supportsConditionalError {
return nil, errors.New("grok OAuth conditional error mutation is not configured")
}
providerState := &tokenRefreshProviderState{
service: s,
registration: registration,
rateGate: s.providerRateGate(PlatformGrok),
poolGate: s.providerConcurrencyGate(PlatformGrok),
}
for i := range accounts {
if err := ctx.Err(); err != nil {
return nil, err
}
account := &accounts[i]
reason, action, actionable := classifyGrokOAuthReconcileAccount(account, refreshWindow)
if !actionable {
result.Skipped++
continue
}
result.Actionable++
item := GrokOAuthReconcileItem{
AccountID: account.ID,
Reason: reason,
Action: action,
Outcome: GrokOAuthReconcileOutcomePlanned,
}
if action == GrokOAuthReconcileActionBlock {
result.WouldBlock++
} else {
result.WouldRefresh++
}
if dryRun {
result.Items = append(result.Items, item)
continue
}
switch action {
case GrokOAuthReconcileActionBlock:
latest, err := s.accountRepo.GetByID(ctx, account.ID)
if err != nil || latest == nil {
item.Outcome = GrokOAuthReconcileOutcomeFailed
result.Failed++
break
}
latestReason, latestAction, stillActionable := classifyGrokOAuthReconcileAccount(latest, refreshWindow)
if !stillActionable || latestAction != GrokOAuthReconcileActionBlock {
// The account changed after page hydration (for example, an admin
// reauthorized it). Never apply a stale destructive action; the next
// resumable scan can plan the fresh state.
item.Outcome = GrokOAuthReconcileOutcomeSkipped
result.Skipped++
break
}
account = latest
item.Reason = latestReason
applied, err := conditionalErrorRepo.SetGrokOAuthErrorIfCredentialsUnchanged(
ctx,
account.ID,
account.Credentials,
"Grok OAuth credential reconciliation: missing refresh token",
)
if err != nil {
item.Outcome = GrokOAuthReconcileOutcomeFailed
result.Failed++
break
}
if !applied {
// Reauthorization won the compare-and-set race after the final
// reread. The runtime fast path is installed only after the CAS
// succeeds, so the fresh active account remains untouched.
item.Outcome = GrokOAuthReconcileOutcomeSkipped
result.Skipped++
break
}
s.notifyAccountSchedulingBlocked(account, time.Time{}, "grok_oauth_reconcile_invalid")
account.Status = StatusError
account.Schedulable = false
cacheInvalidationFailed := s.cacheInvalidator == nil
if s.cacheInvalidator != nil {
if err := s.cacheInvalidator.InvalidateToken(ctx, account); err != nil {
cacheInvalidationFailed = true
}
}
result.Blocked++
if cacheInvalidationFailed {
item.Outcome = GrokOAuthReconcileOutcomePartial
result.Partial++
} else {
item.Outcome = GrokOAuthReconcileOutcomeApplied
}
case GrokOAuthReconcileActionRefresh:
if providerState.isTripped() {
item.Outcome = GrokOAuthReconcileOutcomeSkipped
result.Skipped++
break
}
if providerState.isTripped() {
item.Outcome = GrokOAuthReconcileOutcomeSkipped
result.Skipped++
break
}
refreshErr := s.refreshWithRetryWithRateGate(ctx, account, registration.refresher, registration.executor, refreshWindow, providerState)
providerState.recordResult(refreshErr)
var permanentErr *accountPermanentRefreshError
switch {
case refreshErr == nil:
item.Outcome = GrokOAuthReconcileOutcomeApplied
result.Refreshed++
case errors.Is(refreshErr, errRefreshSkipped):
item.Outcome = GrokOAuthReconcileOutcomeSkipped
result.Skipped++
case errors.As(refreshErr, &permanentErr) && permanentErr.persistentlyBlocked:
item.Reason = GrokOAuthReconcileReasonCredentialRejected
item.Action = GrokOAuthReconcileActionBlock
result.Blocked++
if permanentErr.cacheInvalidationFailed {
item.Outcome = GrokOAuthReconcileOutcomePartial
result.Partial++
} else {
item.Outcome = GrokOAuthReconcileOutcomeApplied
}
default:
item.Outcome = GrokOAuthReconcileOutcomeFailed
result.Failed++
}
default:
return nil, fmt.Errorf("unsupported Grok OAuth reconciliation action")
}
result.Items = append(result.Items, item)
}
return result, nil
}
func (s *TokenRefreshService) grokOAuthReconcileMaxPageSize() int {
return maxGrokOAuthReconcilePageSize
}
func (s *TokenRefreshService) grokRegistration() (tokenRefreshRegistration, bool) {
for _, registration := range s.registrations {
if registration.platform == PlatformGrok && registration.refresher != nil {
return registration, true
}
}
return tokenRefreshRegistration{}, false
}
func classifyGrokOAuthReconcileAccount(account *Account, refreshWindow time.Duration) (reason, action string, actionable bool) {
if account == nil || !account.IsGrokOAuth() || account.Status != StatusActive {
return "", "", false
}
if strings.TrimSpace(account.GetGrokRefreshToken()) == "" {
return GrokOAuthReconcileReasonMissingRefreshToken, GrokOAuthReconcileActionBlock, true
}
if strings.TrimSpace(account.GetGrokAccessToken()) == "" {
return GrokOAuthReconcileReasonMissingAccessToken, GrokOAuthReconcileActionRefresh, true
}
rawExpiry := strings.TrimSpace(account.GetCredential("expires_at"))
if rawExpiry == "" {
return GrokOAuthReconcileReasonMissingExpiry, GrokOAuthReconcileActionRefresh, true
}
expiresAt := account.GetCredentialAsTime("expires_at")
if expiresAt == nil {
return GrokOAuthReconcileReasonInvalidExpiry, GrokOAuthReconcileActionRefresh, true
}
if time.Until(*expiresAt) <= refreshWindow {
return GrokOAuthReconcileReasonNearExpiry, GrokOAuthReconcileActionRefresh, true
}
return "", "", false
}