Files
gpt-load/internal/control/idempotency_operation.go
tbphp de6d2d44fc feat(access-keys): 支持自定义密钥并统一编辑与生成交互 (#611)
* feat(access-keys): 支持自定义密钥与强度提示

* fix(access-keys): 恢复默认掩码并按密钥长度分档

* feat(access-keys): 统一密钥编辑与随机生成交互

* style(access-keys): 改用骰子图标生成密钥

* fix(access-keys): 修复筛选幂等判定与恢复状态提示

* fix(access-keys): 统一编辑请求的幂等摘要
2026-09-09 23:05:09 +08:00

532 lines
16 KiB
Go

package control
import (
"bytes"
"context"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"gorm.io/gorm"
"gpt-load/internal/platform/canonicaljson"
"gpt-load/internal/platform/epochms"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/pricing"
stateloader "gpt-load/internal/state/loader"
"gpt-load/internal/storage/models"
)
type operationStage string
const (
operationStageDBCommitted operationStage = "db_committed"
operationStagePricesPublished operationStage = "prices_published"
operationStageRegistryApplied operationStage = "registry_applied"
operationStageSnapshotPublished operationStage = "snapshot_published"
operationStageCompleted operationStage = "completed"
)
type operationTransaction = *gorm.DB
type idempotentMutationResult struct {
ResourceIdentity string
CanonicalResult []byte
Ephemeral any
}
type idempotentOperationInput struct {
IdempotencyKey string
DigestVersion uint
RequestDigest [32]byte
Kind operationKind
CredentialMutationID uint
PrepareMutation func()
Mutate func(operationTransaction) (idempotentMutationResult, error)
}
type idempotentOperationResult struct {
OperationID string
ResourceIdentity string
CanonicalResult []byte
Replayed bool
Ephemeral any
}
type operationErrorData struct {
OperationID string `json:"operation_id"`
OperationKind operationKind `json:"operation_kind"`
}
type operationExpiredData struct {
OperationID string `json:"operation_id"`
OperationKind operationKind `json:"operation_kind"`
ResourceIdentity string `json:"resource_identity"`
CompletedAtMS int64 `json:"completed_at_ms"`
}
func operationRequiredStages(kind operationKind) ([]operationStage, error) {
var stages []operationStage
switch kind {
case operationKindAccessKeyCreate, operationKindAccessKeyRotate, operationKindAccessKeyUpdate:
stages = []operationStage{
operationStageDBCommitted,
operationStageSnapshotPublished,
operationStageCompleted,
}
case operationKindGroupCreate:
stages = []operationStage{
operationStageDBCommitted,
operationStagePricesPublished,
operationStageRegistryApplied,
operationStageSnapshotPublished,
operationStageCompleted,
}
case operationKindCredentialImport:
stages = []operationStage{
operationStageDBCommitted,
operationStageRegistryApplied,
operationStageCompleted,
}
default:
return nil, fmt.Errorf("unsupported control operation kind %q", kind)
}
return append([]operationStage(nil), stages...), nil
}
func validateIdempotencyKey(value string) error {
if len(value) != 36 ||
value[8] != '-' ||
value[13] != '-' ||
value[18] != '-' ||
value[23] != '-' ||
value[14] != '4' {
return fmt.Errorf("idempotency key must be a canonical lowercase UUID v4")
}
switch value[19] {
case '8', '9', 'a', 'b':
default:
return fmt.Errorf("idempotency key must be a canonical lowercase UUID v4")
}
for index, character := range []byte(value) {
if index == 8 || index == 13 || index == 18 || index == 23 {
continue
}
if character < '0' || character > '9' && character < 'a' || character > 'f' {
return fmt.Errorf("idempotency key must be a canonical lowercase UUID v4")
}
}
return nil
}
func newOperationID(random io.Reader) (string, error) {
value := make([]byte, 16)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("generate operation id: %w", err)
}
value[6] = value[6]&0x0f | 0x40
value[8] = value[8]&0x3f | 0x80
encoded := hex.EncodeToString(value)
return encoded[0:8] + "-" +
encoded[8:12] + "-" +
encoded[12:16] + "-" +
encoded[16:20] + "-" +
encoded[20:32], nil
}
func (s *Service) executeIdempotentOperation(
ctx context.Context,
input idempotentOperationInput,
) (idempotentOperationResult, error) {
if err := validateIdempotencyKey(input.IdempotencyKey); err != nil {
return idempotentOperationResult{}, app_errors.ErrInvalidIdempotencyKey
}
if input.DigestVersion != 1 || !input.Kind.valid() || input.Mutate == nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
var existing models.ControlOperation
query := s.db.WithContext(ctx).
Where("idempotency_key = ?", input.IdempotencyKey).
Take(&existing)
if query.Error == nil {
if err := validateOperationComparator(&existing, input); err != nil {
return idempotentOperationResult{}, err
}
if recoveryErr := s.enforceOperationRecoveryBarrierLocked(
ctx,
existing.CommitSequence,
); recoveryErr != nil {
return idempotentOperationResult{}, recoveryErr
}
return s.replayIdempotentOperationLocked(ctx, &existing, input)
}
if !errors.Is(query.Error, gorm.ErrRecordNotFound) {
return idempotentOperationResult{}, app_errors.ParseDBError(query.Error)
}
if recoveryErr := s.enforceOperationRecoveryBarrierLocked(ctx, 0); recoveryErr != nil {
return idempotentOperationResult{}, recoveryErr
}
if input.PrepareMutation != nil {
input.PrepareMutation()
}
requiredStages, err := operationRequiredStages(input.Kind)
if err != nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
encodedStages, err := json.Marshal(requiredStages)
if err != nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
operationID, err := newOperationID(s.operationRandom)
if err != nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
var mutationResult idempotentMutationResult
var operation models.ControlOperation
mutate := func() {
err = s.withControlTransaction(ctx, func(tx *gorm.DB) error {
var mutationErr error
mutationResult, mutationErr = input.Mutate(tx)
if mutationErr != nil {
return mutationErr
}
if mutationResult.ResourceIdentity == "" || len(mutationResult.CanonicalResult) == 0 {
return fmt.Errorf(
"idempotent mutation result is incomplete: %w",
app_errors.ErrInternalServer,
)
}
canonicalResult, canonicalErr := canonicaljson.Canonicalize(
mutationResult.CanonicalResult,
)
if canonicalErr != nil || !bytes.Equal(canonicalResult, mutationResult.CanonicalResult) {
return fmt.Errorf(
"idempotent mutation result is not canonical: %w",
app_errors.ErrInternalServer,
)
}
nowMS, timeErr := epochms.FromTime(s.now())
if timeErr != nil {
return app_errors.ErrInternalServer
}
operation = models.ControlOperation{
OperationID: operationID,
IdempotencyKey: input.IdempotencyKey,
DigestVersion: input.DigestVersion,
RequestDigest: append([]byte(nil), input.RequestDigest[:]...),
OperationKind: string(input.Kind),
ResourceIdentity: mutationResult.ResourceIdentity,
CanonicalResult: append([]byte(nil), mutationResult.CanonicalResult...),
RequiredStages: models.JSON(encodedStages),
LastCompletedStage: string(operationStageDBCommitted),
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
if err := tx.Create(&operation).Error; err != nil {
return app_errors.ParseDBError(err)
}
return nil
})
}
if input.CredentialMutationID != 0 && s.mutations != nil {
s.mutations.Do(input.CredentialMutationID, mutate)
} else {
mutate()
}
if err != nil {
return idempotentOperationResult{}, err
}
if err := s.recoverOperationLocked(ctx, &operation); err != nil {
s.wakeOperationRecovery()
return idempotentOperationResult{}, s.operationIncompleteError(operation)
}
return idempotentOperationResult{
OperationID: operation.OperationID,
ResourceIdentity: operation.ResourceIdentity,
CanonicalResult: append([]byte(nil), operation.CanonicalResult...),
Ephemeral: mutationResult.Ephemeral,
}, nil
}
func (s *Service) replayIdempotentOperationLocked(
ctx context.Context,
operation *models.ControlOperation,
input idempotentOperationInput,
) (idempotentOperationResult, error) {
if err := validateOperationComparator(operation, input); err != nil {
return idempotentOperationResult{}, err
}
if operation.CompactedAtMS != nil {
if operation.CompletedAtMS == nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
if err := validateSafeMilliseconds(*operation.CompletedAtMS); err != nil {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
return idempotentOperationResult{}, app_errors.NewAPIErrorWithData(
app_errors.ErrIdempotencyResultExpired,
operationExpiredData{
OperationID: operation.OperationID,
OperationKind: operationKind(operation.OperationKind),
ResourceIdentity: operation.ResourceIdentity,
CompletedAtMS: *operation.CompletedAtMS,
},
)
}
if operation.LastCompletedStage != string(operationStageCompleted) {
if err := s.recoverOperationLocked(ctx, operation); err != nil {
s.wakeOperationRecovery()
return idempotentOperationResult{}, s.operationIncompleteError(*operation)
}
}
if operation.CompletedAtMS == nil || len(operation.CanonicalResult) == 0 {
return idempotentOperationResult{}, app_errors.ErrInternalServer
}
return idempotentOperationResult{
OperationID: operation.OperationID,
ResourceIdentity: operation.ResourceIdentity,
CanonicalResult: append([]byte(nil), operation.CanonicalResult...),
Replayed: true,
}, nil
}
func validateOperationComparator(
operation *models.ControlOperation,
input idempotentOperationInput,
) error {
if operation.DigestVersion != 1 ||
input.DigestVersion != 1 ||
len(operation.RequestDigest) != len(input.RequestDigest) ||
!operationKind(operation.OperationKind).valid() {
return app_errors.ErrInternalServer
}
if subtle.ConstantTimeCompare(operation.RequestDigest, input.RequestDigest[:]) != 1 {
return app_errors.NewAPIErrorWithData(
app_errors.ErrIdempotencyKeyReused,
operationErrorData{
OperationID: operation.OperationID,
OperationKind: operationKind(operation.OperationKind),
},
)
}
return nil
}
func (s *Service) recoverOperationLocked(
ctx context.Context,
operation *models.ControlOperation,
) error {
if err := validateRecoverableOperation(operation); err != nil {
return err
}
kind := operationKind(operation.OperationKind)
wantStages, err := operationRequiredStages(kind)
if err != nil {
return err
}
var storedStages []operationStage
if err := json.Unmarshal(operation.RequiredStages, &storedStages); err != nil ||
!sameOperationStages(storedStages, wantStages) {
return fmt.Errorf("invalid durable operation stage plan")
}
currentIndex := -1
for index, stage := range storedStages {
if string(stage) == operation.LastCompletedStage {
currentIndex = index
break
}
}
if currentIndex < 0 {
return fmt.Errorf("invalid durable operation current stage")
}
for index := currentIndex + 1; index < len(storedStages); index++ {
stage := storedStages[index]
var stageErr error
switch stage {
case operationStagePricesPublished:
var table *pricing.Table
table, stageErr = loadPriceTable(ctx, s.db)
if stageErr == nil {
s.priceRuntime.Publish(table)
}
case operationStageRegistryApplied:
stageErr = s.recoverRegistryOperation(ctx, operation)
case operationStageSnapshotPublished:
compileInput, inputErr := stateloader.BuildCompileInputWithProxy(
ctx, s.db, s.encryption, s.environmentProxy, s.channelRegistry,
)
if inputErr != nil {
stageErr = inputErr
} else {
var matches bool
matches, stageErr = s.manager.Matches(compileInput)
if stageErr == nil && !matches {
_, stageErr = s.publishSnapshot(compileInput)
}
}
case operationStageCompleted:
default:
stageErr = fmt.Errorf("unsupported operation stage %q", stage)
}
if stageErr != nil {
_ = s.recordOperationFailureLocked(ctx, operation, stage)
return fmt.Errorf("recover operation stage %s", stage)
}
if err := s.advanceOperationStageLocked(ctx, operation, stage); err != nil {
_ = s.recordOperationFailureLocked(ctx, operation, stage)
return err
}
}
return nil
}
func (s *Service) recoverRegistryOperation(
ctx context.Context,
operation *models.ControlOperation,
) error {
groupID, err := operationGroupID(operation)
if err != nil {
return err
}
var credentialIDs []uint
if err := s.db.WithContext(ctx).Model(&models.Credential{}).
Where("group_id = ?", groupID).Order("id ASC").Pluck("id", &credentialIDs).Error; err != nil {
return app_errors.ParseDBError(err)
}
var reconcileErr error
reconcile := func() {
entries, buildErr := stateloader.BuildGroupCredentialEntriesWithProxy(ctx, s.db, groupID, s.encryption)
if buildErr != nil {
reconcileErr = buildErr
return
}
if s.registry.MatchesGroup(groupID, entries) {
return
}
_, reconcileErr = s.reconcileRegistryGroup(groupID, entries)
}
if err := s.doCredentialMutations(credentialIDs, reconcile); err != nil {
return err
}
return reconcileErr
}
func (s *Service) advanceOperationStageLocked(
ctx context.Context,
operation *models.ControlOperation,
stage operationStage,
) error {
if s.beforeAdvanceOperationStage != nil {
if err := s.beforeAdvanceOperationStage(ctx, operation, stage); err != nil {
return err
}
}
previous := operation.LastCompletedStage
nowMS, timeErr := epochms.FromTime(s.now())
if timeErr != nil {
return app_errors.ErrInternalServer
}
updates := map[string]any{
"last_completed_stage": string(stage),
"failed_stage": "",
"updated_at_ms": nowMS,
}
if stage == operationStageCompleted {
updates["completed_at_ms"] = nowMS
}
err := s.withControlTransaction(ctx, func(tx *gorm.DB) error {
result := tx.Model(&models.ControlOperation{}).
Where(
"commit_sequence = ? AND last_completed_stage = ?",
operation.CommitSequence,
previous,
).
Updates(updates)
if result.Error != nil {
return app_errors.ParseDBError(result.Error)
}
if result.RowsAffected != 1 {
return fmt.Errorf("advance operation stage: durable stage changed")
}
return nil
})
if err != nil {
return err
}
operation.LastCompletedStage = string(stage)
operation.FailedStage = ""
operation.UpdatedAtMS = nowMS
if stage == operationStageCompleted {
operation.CompletedAtMS = &nowMS
}
return nil
}
func (s *Service) recordOperationFailureLocked(
ctx context.Context,
operation *models.ControlOperation,
stage operationStage,
) error {
nowMS, timeErr := epochms.FromTime(s.now())
if timeErr != nil {
return app_errors.ErrInternalServer
}
err := s.withControlTransaction(ctx, func(tx *gorm.DB) error {
return tx.Model(&models.ControlOperation{}).
Where("commit_sequence = ?", operation.CommitSequence).
Updates(map[string]any{
"failed_stage": string(stage),
"updated_at_ms": nowMS,
}).Error
})
if err == nil {
operation.FailedStage = string(stage)
operation.UpdatedAtMS = nowMS
}
return err
}
func (s *Service) operationIncompleteError(
operation models.ControlOperation,
) error {
return app_errors.NewAPIErrorWithData(
app_errors.ErrControlOperationIncomplete,
struct {
OperationID string `json:"operation_id"`
OperationKind operationKind `json:"operation_kind"`
LastCompletedStage string `json:"last_completed_stage"`
FailedStage string `json:"failed_stage"`
CanReconcile bool `json:"can_reconcile"`
}{
OperationID: operation.OperationID,
OperationKind: operationKind(operation.OperationKind),
LastCompletedStage: operation.LastCompletedStage,
FailedStage: operation.FailedStage,
CanReconcile: true,
},
)
}
func sameOperationStages(left, right []operationStage) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}