Files
MyGoNavi/internal/app/security_update_engine.go
2026-04-10 21:29:45 +08:00

562 lines
20 KiB
Go

package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"GoNavi-Wails/internal/ai"
aiservice "GoNavi-Wails/internal/ai/service"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
)
type securityUpdateNormalizedPreview struct {
SourceType SecurityUpdateSourceType `json:"sourceType"`
ConnectionIDs []string `json:"connectionIds"`
HasGlobalProxy bool `json:"hasGlobalProxy"`
AIProviderIDs []string `json:"aiProviderIds"`
AIProvidersNeedingAttention []string `json:"aiProvidersNeedingAttention,omitempty"`
}
func (a *App) GetSecurityUpdateStatus() (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.LoadMarker()
if err != nil {
if os.IsNotExist(err) {
inspection, inspectErr := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Inspect()
if inspectErr != nil {
return SecurityUpdateStatus{}, inspectErr
}
if len(inspection.ProvidersNeedingMigration) > 0 {
return buildSecurityUpdatePendingStatusFromInspection(inspection, SecurityUpdateOverallStatusPending), nil
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
OverallStatus: SecurityUpdateOverallStatusNotDetected,
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}, nil
}
return SecurityUpdateStatus{}, err
}
return status, nil
}
func (a *App) StartSecurityUpdate(request StartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.StartRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
return a.executeSecurityUpdateRound(repo, status, request.SourceType, request.RawPayload)
}
func (a *App) RetrySecurityUpdateCurrentRound(request RetrySecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.RetryRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
previewData, err := os.ReadFile(filepath.Join(status.BackupPath, securityUpdateNormalizedPreviewFileName))
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(status, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
_ = repo.WriteResult(failed)
return failed, nil
}
var preview securityUpdateNormalizedPreview
if err := json.Unmarshal(previewData, &preview); err != nil {
failed := newSecurityUpdateSystemFailureStatus(status, SecurityUpdateIssueReasonCodeValidationFailed, err)
_ = repo.WriteResult(failed)
return failed, nil
}
finalStatus, execErr := a.validateSecurityUpdateCurrentAppRound(status, preview)
if execErr != nil {
_ = repo.WriteResult(finalStatus)
return finalStatus, nil
}
if err := repo.WriteResult(finalStatus); err != nil {
return SecurityUpdateStatus{}, err
}
return finalStatus, nil
}
func (a *App) RestartSecurityUpdate(request RestartSecurityUpdateRequest) (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.RestartRound(request)
if err != nil {
return SecurityUpdateStatus{}, err
}
return a.executeSecurityUpdateRound(repo, status, request.SourceType, request.RawPayload)
}
func (a *App) DismissSecurityUpdateReminder() (SecurityUpdateStatus, error) {
a.updateMu.Lock()
defer a.updateMu.Unlock()
now := nowRFC3339()
repo := newSecurityUpdateStateRepository(a.configDir)
status, err := repo.LoadMarker()
if err != nil {
if !os.IsNotExist(err) {
return SecurityUpdateStatus{}, err
}
inspection, inspectErr := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Inspect()
if inspectErr != nil {
return SecurityUpdateStatus{}, inspectErr
}
if len(inspection.ProvidersNeedingMigration) > 0 {
status = buildSecurityUpdatePendingStatusFromInspection(inspection, SecurityUpdateOverallStatusPostponed)
} else {
status = SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}
}
}
status.SchemaVersion = securityUpdateSchemaVersion
if strings.TrimSpace(string(status.SourceType)) == "" {
status.SourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
if status.Issues == nil {
status.Issues = []SecurityUpdateIssue{}
}
if status.OverallStatus == SecurityUpdateOverallStatusCompleted || status.OverallStatus == SecurityUpdateOverallStatusRolledBack {
return status, nil
}
status.OverallStatus = SecurityUpdateOverallStatusPostponed
status.PostponedAt = now
status.UpdatedAt = now
if err := repo.WriteResult(status); err != nil {
return SecurityUpdateStatus{}, err
}
return repo.LoadMarker()
}
func (a *App) executeSecurityUpdateRound(repo *securityUpdateStateRepository, round SecurityUpdateStatus, sourceType SecurityUpdateSourceType, rawPayload string) (SecurityUpdateStatus, error) {
if strings.TrimSpace(string(sourceType)) == "" {
sourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
if sourceType != SecurityUpdateSourceTypeCurrentAppSavedConfig {
failed := newSecurityUpdateSystemFailureStatus(round, SecurityUpdateIssueReasonCodeValidationFailed, fmt.Errorf("unsupported source type: %s", sourceType))
_ = repo.WriteResult(failed)
return failed, nil
}
source, rawParsed, err := parseSecurityUpdateCurrentAppSource(rawPayload)
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(round, SecurityUpdateIssueReasonCodeValidationFailed, err)
_ = repo.WriteResult(failed)
return failed, nil
}
rollbackSnapshot, err := captureSecurityUpdateCurrentAppRollbackSnapshot(a, source)
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(round, securityUpdateFailureReasonForError(err), err)
_ = repo.WriteResult(failed)
return failed, nil
}
if err := securityUpdateWriteJSONFile(filepath.Join(round.BackupPath, securityUpdateSourceCurrentAppFileName), rawParsed); err != nil {
return SecurityUpdateStatus{}, err
}
finalStatus, preview, execErr := a.runSecurityUpdateCurrentAppRound(round, source)
if previewErr := securityUpdateWriteJSONFile(filepath.Join(round.BackupPath, securityUpdateNormalizedPreviewFileName), preview); previewErr != nil {
return a.rollbackSecurityUpdatePersistenceFailure(repo, rollbackSnapshot, finalStatus, previewErr)
}
if execErr != nil {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(rollbackErr), rollbackErr)
_ = repo.WriteResult(failed)
return failed, nil
}
_ = repo.WriteResult(finalStatus)
return finalStatus, nil
}
if err := repo.WriteResult(finalStatus); err != nil {
return a.rollbackSecurityUpdatePersistenceFailure(repo, rollbackSnapshot, finalStatus, err)
}
return finalStatus, nil
}
func (a *App) rollbackSecurityUpdatePersistenceFailure(
repo *securityUpdateStateRepository,
rollbackSnapshot securityUpdateCurrentAppRollbackSnapshot,
base SecurityUpdateStatus,
cause error,
) (SecurityUpdateStatus, error) {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
failed := newSecurityUpdateSystemFailureStatus(base, securityUpdateFailureReasonForError(rollbackErr), rollbackErr)
_ = repo.WriteResult(failed)
return failed, nil
}
failed := newSecurityUpdateSystemFailureStatus(base, SecurityUpdateIssueReasonCodeEnvironmentBlocked, cause)
_ = repo.WriteResult(failed)
return failed, nil
}
func (a *App) runSecurityUpdateCurrentAppRound(round SecurityUpdateStatus, source securityUpdateCurrentAppSource) (SecurityUpdateStatus, securityUpdateNormalizedPreview, error) {
finalStatus := newSecurityUpdateRoundBaseStatus(round, SecurityUpdateSourceTypeCurrentAppSavedConfig)
preview := securityUpdateNormalizedPreview{
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
ConnectionIDs: make([]string, 0, len(source.Connections)),
HasGlobalProxy: source.GlobalProxy != nil,
AIProviderIDs: []string{},
}
connectionRepo := a.savedConnectionRepository()
for _, item := range source.Connections {
finalStatus.Summary.Total++
preview.ConnectionIDs = append(preview.ConnectionIDs, item.ID)
if _, err := connectionRepo.Save(connection.SavedConnectionInput(item)); err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, preview, err
}
finalStatus.Summary.Updated++
}
if source.GlobalProxy != nil {
finalStatus.Summary.Total++
if _, err := a.saveGlobalProxy(connection.SaveGlobalProxyInput(*source.GlobalProxy)); err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, preview, err
}
finalStatus.Summary.Updated++
}
providerSnapshot, err := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Load()
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, preview, err
}
for _, provider := range providerSnapshot.Providers {
if !providerParticipatesInSecurityUpdate(provider) {
continue
}
preview.AIProviderIDs = append(preview.AIProviderIDs, provider.ID)
finalStatus.Summary.Total++
if provider.HasSecret && strings.TrimSpace(provider.APIKey) == "" {
finalStatus.OverallStatus = SecurityUpdateOverallStatusNeedsAttention
finalStatus.Summary.Pending++
finalStatus.Issues = append(finalStatus.Issues, SecurityUpdateIssue{
ID: "ai-provider-" + provider.ID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: provider.ID,
Title: provider.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeSecretMissing,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置需要补充后才能完成安全更新",
})
preview.AIProvidersNeedingAttention = append(preview.AIProvidersNeedingAttention, provider.ID)
continue
}
finalStatus.Summary.Updated++
}
if finalStatus.OverallStatus == SecurityUpdateOverallStatusCompleted {
finalStatus.CompletedAt = finalStatus.UpdatedAt
}
return finalStatus, preview, nil
}
func (a *App) validateSecurityUpdateCurrentAppRound(round SecurityUpdateStatus, preview securityUpdateNormalizedPreview) (SecurityUpdateStatus, error) {
if strings.TrimSpace(string(preview.SourceType)) == "" {
preview.SourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
finalStatus := newSecurityUpdateRoundBaseStatus(round, preview.SourceType)
connectionRepo := a.savedConnectionRepository()
for _, id := range preview.ConnectionIDs {
finalStatus.Summary.Total++
savedConnection, err := connectionRepo.Find(id)
if err != nil {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "connection-" + id,
Scope: SecurityUpdateIssueScopeConnection,
RefID: id,
Title: id,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenConnection,
Message: "连接配置已不存在或仍需重新保存后才能完成安全更新",
},
)
continue
}
if _, err := a.resolveConnectionSecrets(savedConnection.Config); err != nil {
if secretstore.IsUnavailable(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, err
}
reason := SecurityUpdateIssueReasonCodeValidationFailed
message := "连接配置仍需补充后才能完成安全更新"
if os.IsNotExist(err) {
reason = SecurityUpdateIssueReasonCodeSecretMissing
message = "连接密码已丢失,请重新保存后再继续"
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "connection-" + id,
Scope: SecurityUpdateIssueScopeConnection,
RefID: id,
Title: savedConnection.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: reason,
Action: SecurityUpdateIssueActionOpenConnection,
Message: message,
},
)
continue
}
finalStatus.Summary.Updated++
}
if preview.HasGlobalProxy {
finalStatus.Summary.Total++
proxyView, err := a.loadStoredGlobalProxyView()
if err != nil {
if !os.IsNotExist(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, err
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "global-proxy-default",
Scope: SecurityUpdateIssueScopeGlobalProxy,
Title: "全局代理",
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenProxySettings,
Message: "全局代理配置已不存在或仍需重新保存后才能完成安全更新",
},
)
} else {
if proxyView.HasPassword {
if _, err := a.loadGlobalProxySecretBundle(proxyView); err != nil {
if secretstore.IsUnavailable(err) {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, SecurityUpdateIssueReasonCodeEnvironmentBlocked, err)
return failed, err
}
reason := SecurityUpdateIssueReasonCodeValidationFailed
message := "全局代理密码仍需补充后才能完成安全更新"
if os.IsNotExist(err) {
reason = SecurityUpdateIssueReasonCodeSecretMissing
message = "全局代理密码已丢失,请重新保存后再继续"
}
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "global-proxy-default",
Scope: SecurityUpdateIssueScopeGlobalProxy,
Title: "全局代理",
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: reason,
Action: SecurityUpdateIssueActionOpenProxySettings,
Message: message,
},
)
goto validateProviders
}
}
finalStatus.Summary.Updated++
}
}
validateProviders:
providerSnapshot, err := aiservice.NewProviderConfigStore(a.configDir, a.secretStore).Load()
if err != nil {
failed := newSecurityUpdateSystemFailureStatus(finalStatus, securityUpdateFailureReasonForError(err), err)
return failed, err
}
providersByID := make(map[string]ai.ProviderConfig, len(providerSnapshot.Providers))
for _, provider := range providerSnapshot.Providers {
providersByID[provider.ID] = provider
}
for _, providerID := range preview.AIProviderIDs {
finalStatus.Summary.Total++
provider, ok := providersByID[providerID]
if !ok {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "ai-provider-" + providerID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: providerID,
Title: providerID,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeValidationFailed,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置已不存在或仍需重新保存后才能完成安全更新",
},
)
continue
}
if provider.HasSecret && strings.TrimSpace(provider.APIKey) == "" {
markSecurityUpdateNeedsAttention(
&finalStatus,
SecurityUpdateIssue{
ID: "ai-provider-" + provider.ID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: provider.ID,
Title: provider.Name,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusNeedsAttention,
ReasonCode: SecurityUpdateIssueReasonCodeSecretMissing,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置需要补充后才能完成安全更新",
},
)
continue
}
finalStatus.Summary.Updated++
}
if finalStatus.OverallStatus == SecurityUpdateOverallStatusCompleted {
finalStatus.CompletedAt = finalStatus.UpdatedAt
}
return finalStatus, nil
}
func providerParticipatesInSecurityUpdate(provider ai.ProviderConfig) bool {
return provider.HasSecret || strings.TrimSpace(provider.APIKey) != ""
}
func buildSecurityUpdatePendingStatusFromInspection(
inspection aiservice.ProviderConfigStoreInspection,
overallStatus SecurityUpdateOverallStatus,
) SecurityUpdateStatus {
providersByID := make(map[string]ai.ProviderConfig, len(inspection.Snapshot.Providers))
for _, provider := range inspection.Snapshot.Providers {
providersByID[provider.ID] = provider
}
issues := make([]SecurityUpdateIssue, 0, len(inspection.ProvidersNeedingMigration))
for _, providerID := range inspection.ProvidersNeedingMigration {
provider := providersByID[providerID]
title := strings.TrimSpace(provider.Name)
if title == "" {
title = providerID
}
issues = append(issues, SecurityUpdateIssue{
ID: "ai-provider-" + providerID,
Scope: SecurityUpdateIssueScopeAIProvider,
RefID: providerID,
Title: title,
Severity: SecurityUpdateIssueSeverityMedium,
Status: SecurityUpdateItemStatusPending,
ReasonCode: SecurityUpdateIssueReasonCodeMigrationRequired,
Action: SecurityUpdateIssueActionOpenAISettings,
Message: "AI 提供商配置仍保存在当前应用配置中,完成安全更新后会迁入新的安全存储。",
})
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
OverallStatus: overallStatus,
SourceType: SecurityUpdateSourceTypeCurrentAppSavedConfig,
ReminderVisible: overallStatus == SecurityUpdateOverallStatusPending,
CanStart: overallStatus == SecurityUpdateOverallStatusPending || overallStatus == SecurityUpdateOverallStatusPostponed,
CanPostpone: overallStatus == SecurityUpdateOverallStatusPending || overallStatus == SecurityUpdateOverallStatusPostponed,
Summary: SecurityUpdateSummary{
Total: len(issues),
Pending: len(issues),
},
Issues: issues,
}
}
func newSecurityUpdateRoundBaseStatus(round SecurityUpdateStatus, sourceType SecurityUpdateSourceType) SecurityUpdateStatus {
if strings.TrimSpace(string(sourceType)) == "" {
sourceType = SecurityUpdateSourceTypeCurrentAppSavedConfig
}
return SecurityUpdateStatus{
SchemaVersion: securityUpdateSchemaVersion,
MigrationID: round.MigrationID,
OverallStatus: SecurityUpdateOverallStatusCompleted,
SourceType: sourceType,
BackupAvailable: round.BackupAvailable || strings.TrimSpace(round.BackupPath) != "",
BackupPath: round.BackupPath,
StartedAt: round.StartedAt,
UpdatedAt: nowRFC3339(),
Summary: SecurityUpdateSummary{},
Issues: []SecurityUpdateIssue{},
}
}
func markSecurityUpdateNeedsAttention(status *SecurityUpdateStatus, issue SecurityUpdateIssue) {
status.OverallStatus = SecurityUpdateOverallStatusNeedsAttention
status.Summary.Pending++
status.Issues = append(status.Issues, issue)
}
func securityUpdateFailureReasonForError(err error) SecurityUpdateIssueReasonCode {
if secretstore.IsUnavailable(err) {
return SecurityUpdateIssueReasonCodeEnvironmentBlocked
}
return SecurityUpdateIssueReasonCodeValidationFailed
}
func newSecurityUpdateSystemFailureStatus(base SecurityUpdateStatus, reasonCode SecurityUpdateIssueReasonCode, err error) SecurityUpdateStatus {
status := base
status.SchemaVersion = securityUpdateSchemaVersion
status.OverallStatus = SecurityUpdateOverallStatusRolledBack
status.BackupAvailable = status.BackupAvailable || strings.TrimSpace(status.BackupPath) != ""
status.UpdatedAt = nowRFC3339()
status.CompletedAt = ""
status.LastError = err.Error()
status.Summary.Failed++
status.Issues = []SecurityUpdateIssue{
{
ID: "system-blocked",
Scope: SecurityUpdateIssueScopeSystem,
Title: "安全更新未完成",
Severity: SecurityUpdateIssueSeverityHigh,
Status: SecurityUpdateItemStatusFailed,
ReasonCode: reasonCode,
Action: SecurityUpdateIssueActionViewDetails,
Message: "当前环境无法完成本次安全更新,请稍后重试",
},
}
return status
}