🐛 fix(security): 修复安全更新重检卡死与 Redis 密文兼容

This commit is contained in:
tianqijiuyun-latiao
2026-04-11 20:12:23 +08:00
parent 82e06bd94d
commit 1751e14d20
15 changed files with 585 additions and 54 deletions

View File

@@ -48,6 +48,7 @@ import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateRepairEntry,
resolveSecurityUpdateSettingsFocusTarget,
shouldRefreshSecurityUpdateDetailsFocus,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
type SecurityUpdateRepairSource,
@@ -276,6 +277,7 @@ function App() {
status?: Partial<SecurityUpdateStatus> | null,
options?: {
openSettings?: boolean;
refreshFocus?: boolean;
resetBannerDismissed?: boolean;
},
) => {
@@ -287,8 +289,10 @@ function App() {
setIsSecurityUpdateBannerDismissed(false);
}
if (options?.openSettings) {
setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus));
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
if (options.refreshFocus !== false) {
setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus));
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
}
setIsSecurityUpdateSettingsOpen(true);
}
return nextStatus;
@@ -845,12 +849,16 @@ function App() {
const stageText = mode === 'retry'
? '正在校验更新结果'
: '正在更新安全存储';
const detailsWereOpen = isSecurityUpdateSettingsOpen;
setSecurityUpdateProgressStage(stageText);
setIsSecurityUpdateProgressOpen(true);
setIsSecurityUpdateIntroOpen(false);
setIsSecurityUpdateSettingsOpen(false);
let nextStatus: SecurityUpdateStatus | null = null;
let shouldOpenSettings = false;
let refreshSettingsFocus = false;
try {
let nextStatus: SecurityUpdateStatus | null = null;
if (mode === 'start') {
const result = await startSecurityUpdateFromBootstrap({
backend: backendApp,
@@ -891,29 +899,44 @@ function App() {
}, nextStatus);
}
const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
refreshSettingsFocus = shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: shouldOpenSettings,
wasOpen: detailsWereOpen,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
void message.success('已保存配置已完成安全更新');
} else if (nextStatus.overallStatus === 'needs_attention') {
void message.warning('更新尚未完成,有少量配置需要你处理');
} else if (nextStatus.overallStatus === 'rolled_back') {
void message.warning('本次更新未完成,系统已保留当前可用配置');
}
} catch (err: any) {
console.warn('Failed to execute security update round', err);
void message.error(err?.message || '安全更新未完成,请稍后重试');
} finally {
setIsSecurityUpdateProgressOpen(false);
if (detailsWereOpen) {
setIsSecurityUpdateSettingsOpen(true);
}
void message.error(err?.message || '安全更新未完成,请稍后重试');
return;
}
if (!nextStatus) {
setIsSecurityUpdateProgressOpen(false);
return;
}
setIsSecurityUpdateProgressOpen(false);
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
refreshFocus: refreshSettingsFocus,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
void message.success('已保存配置已完成安全更新');
} else if (nextStatus.overallStatus === 'needs_attention') {
void message.warning('更新尚未完成,有少量配置需要你处理');
} else if (nextStatus.overallStatus === 'rolled_back') {
void message.warning('本次更新未完成,系统已保留当前可用配置');
}
}, [
applySecurityUpdateStatus,
isSecurityUpdateSettingsOpen,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
@@ -2355,6 +2378,7 @@ function App() {
status={securityUpdateStatus}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
@@ -2501,6 +2525,7 @@ function App() {
loading={isSecurityUpdateProgressOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
onStart={handleStartSecurityUpdate}
onPostpone={handlePostponeSecurityUpdate}
onViewDetails={() => handleOpenSecurityUpdateSettings()}
@@ -2509,6 +2534,7 @@ function App() {
open={isSecurityUpdateSettingsOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
status={securityUpdateStatus}
focusTarget={securityUpdateSettingsFocusTarget}
focusRequest={securityUpdateSettingsFocusRequest}
@@ -2522,6 +2548,7 @@ function App() {
open={isSecurityUpdateProgressOpen}
stageText={securityUpdateProgressStage}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
/>
<AISettingsModal
open={isAISettingsOpen}

View File

@@ -15,6 +15,7 @@ interface SecurityUpdateBannerProps {
status: SecurityUpdateStatus;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onRetry: () => void;
onRestart: () => void;
@@ -74,6 +75,7 @@ const SecurityUpdateBanner = ({
status,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onRetry,
onRestart,
@@ -92,7 +94,7 @@ const SecurityUpdateBanner = ({
margin: '12px 12px 0',
padding: '14px 16px',
borderRadius: 16,
...getSecurityUpdateBannerSurfaceStyle(overlayTheme),
...getSecurityUpdateBannerSurfaceStyle(overlayTheme, surfaceOpacity),
display: 'flex',
alignItems: 'center',
gap: 16,

View File

@@ -7,6 +7,7 @@ import {
SECURITY_UPDATE_ACTION_BUTTON_CLASS,
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateActionButtonStyle,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateIntroModalProps {
@@ -14,6 +15,7 @@ interface SecurityUpdateIntroModalProps {
loading?: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
onStart: () => void;
onPostpone: () => void;
onViewDetails: () => void;
@@ -30,6 +32,7 @@ const SecurityUpdateIntroModal = ({
loading = false,
darkMode,
overlayTheme,
surfaceOpacity = 1,
onStart,
onPostpone,
onViewDetails,
@@ -71,12 +74,7 @@ const SecurityUpdateIntroModal = ({
onCancel={onPostpone}
width={560}
styles={{
content: {
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
},
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },

View File

@@ -2,13 +2,17 @@ import { Modal, Spin } from 'antd';
import { SafetyCertificateOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SECURITY_UPDATE_MODAL_CLASS } from '../utils/securityUpdateVisuals';
import {
SECURITY_UPDATE_MODAL_CLASS,
getSecurityUpdateShellSurfaceStyle,
} from '../utils/securityUpdateVisuals';
interface SecurityUpdateProgressModalProps {
open: boolean;
stageText: string;
detailText?: string;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
}
const SecurityUpdateProgressModal = ({
@@ -16,6 +20,7 @@ const SecurityUpdateProgressModal = ({
stageText,
detailText,
overlayTheme,
surfaceOpacity = 1,
}: SecurityUpdateProgressModalProps) => {
return (
<Modal
@@ -28,12 +33,7 @@ const SecurityUpdateProgressModal = ({
width={420}
centered
styles={{
content: {
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
},
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { display: 'none' },
body: { padding: 28 },
}}

View File

@@ -31,6 +31,7 @@ interface SecurityUpdateSettingsModalProps {
open: boolean;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
surfaceOpacity?: number;
status: SecurityUpdateStatus;
focusTarget?: SecurityUpdateSettingsFocusTarget | null;
focusRequest?: number;
@@ -43,11 +44,15 @@ interface SecurityUpdateSettingsModalProps {
const sectionStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity: number,
options?: { emphasized?: boolean },
) => ({
borderRadius: 14,
padding: 16,
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, options),
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, {
...options,
surfaceOpacity,
}),
});
const EMPTY_FOCUS_STATE: SecurityUpdateFocusState = {
@@ -59,6 +64,7 @@ const SecurityUpdateSettingsModal = ({
open,
darkMode,
overlayTheme,
surfaceOpacity = 1,
status,
focusTarget = null,
focusRequest = 0,
@@ -174,7 +180,7 @@ const SecurityUpdateSettingsModal = ({
]}
width={760}
styles={{
content: getSecurityUpdateShellSurfaceStyle(overlayTheme),
content: getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, maxHeight: 640, overflowY: 'auto' },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
@@ -184,7 +190,7 @@ const SecurityUpdateSettingsModal = ({
<div
ref={statusSectionRef}
tabIndex={-1}
style={sectionStyle(overlayTheme, { emphasized: activeFocus.target === 'status' })}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'status' })}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
@@ -211,7 +217,7 @@ const SecurityUpdateSettingsModal = ({
</div>
</div>
<div style={sectionStyle(overlayTheme)}>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
@@ -226,9 +232,8 @@ const SecurityUpdateSettingsModal = ({
<div
key={item.label}
style={{
border: overlayTheme.sectionBorder,
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
background: overlayTheme.sectionBg,
padding: '12px 10px',
}}
>
@@ -239,7 +244,7 @@ const SecurityUpdateSettingsModal = ({
</div>
</div>
<div style={sectionStyle(overlayTheme)}>
<div style={sectionStyle(overlayTheme, surfaceOpacity)}>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 12 }}>
</div>
@@ -258,7 +263,7 @@ const SecurityUpdateSettingsModal = ({
<div
key={issue.id}
style={{
...getSecurityUpdateSectionSurfaceStyle(overlayTheme),
...getSecurityUpdateSectionSurfaceStyle(overlayTheme, { surfaceOpacity }),
borderRadius: 12,
padding: 14,
display: 'flex',
@@ -306,7 +311,7 @@ const SecurityUpdateSettingsModal = ({
SECURITY_UPDATE_RESULT_CARD_CLASS,
activeFocus.target === 'recent_result' ? SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS : '',
].filter(Boolean).join(' ')}
style={sectionStyle(overlayTheme, { emphasized: activeFocus.target === 'recent_result' })}
style={sectionStyle(overlayTheme, surfaceOpacity, { emphasized: activeFocus.target === 'recent_result' })}
>
<div style={{ fontSize: 14, fontWeight: 700, color: overlayTheme.titleText, marginBottom: 8 }}>

View File

@@ -297,6 +297,71 @@ describe('secureConfigBootstrap', () => {
]));
});
it('does not merge local legacy pending items back into an active migration round that already reports needs_attention', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
migrationId: 'migration-active-1',
overallStatus: 'needs_attention',
summary: { total: 3, updated: 2, pending: 1, skipped: 0, failed: 0 },
issues: [
{
id: 'ai-provider-openai-main',
scope: 'ai_provider',
refId: 'openai-main',
title: 'OpenAI',
severity: 'medium',
status: 'needs_attention',
reasonCode: 'secret_missing',
action: 'open_ai_settings',
message: 'AI 提供商配置需要补充后才能完成安全更新。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('needs_attention');
expect(status.summary).toEqual({
total: 3,
updated: 2,
pending: 1,
skipped: 0,
failed: 0,
});
expect(status.issues).toEqual([
expect.objectContaining({ id: 'ai-provider-openai-main', scope: 'ai_provider', refId: 'openai-main' }),
]);
});
it('does not merge local legacy pending items back into a rolled_back migration round', () => {
const status = mergeSecurityUpdateStatusWithLegacySource({
migrationId: 'migration-active-2',
overallStatus: 'rolled_back',
summary: { total: 3, updated: 1, pending: 0, skipped: 0, failed: 2 },
issues: [
{
id: 'system-blocked',
scope: 'system',
title: '系统回滚',
severity: 'high',
status: 'failed',
reasonCode: 'environment_blocked',
action: 'view_details',
message: '后端已回滚本轮更新,需要处理后重试。',
},
],
}, legacyPayload);
expect(status.overallStatus).toBe('rolled_back');
expect(status.summary).toEqual({
total: 3,
updated: 1,
pending: 0,
skipped: 0,
failed: 2,
});
expect(status.issues).toEqual([
expect.objectContaining({ id: 'system-blocked', scope: 'system' }),
]);
});
it('loads backend secure config directly when no legacy source exists', async () => {
const storage = createMemoryStorage();
const replaceConnections = vi.fn();

View File

@@ -193,6 +193,7 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
const hasActiveMigrationRound = String(base.migrationId || '').trim() !== '';
const baseNonLegacyIssues = base.issues.filter((issue) => !isLocalLegacyIssue(issue));
const legacy = buildLegacyPendingDetails(rawPayload);
@@ -231,6 +232,9 @@ export const mergeSecurityUpdateStatusWithLegacySource = (
}
if (base.overallStatus === 'rolled_back' || base.overallStatus === 'needs_attention') {
if (hasActiveMigrationRound) {
return base;
}
return {
...base,
summary: hasMeaningfulSummary(base.summary) || legacy.hasLegacyItems ? legacySummary.summary : legacy.summary,

View File

@@ -6,6 +6,7 @@ import {
resolveSecurityUpdateFocusState,
resolveSecurityUpdateRepairEntry,
resolveSecurityUpdateSettingsFocusTarget,
shouldRefreshSecurityUpdateDetailsFocus,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
} from './securityUpdateRepairFlow';
@@ -136,4 +137,19 @@ describe('securityUpdateRepairFlow', () => {
expect(shouldRetrySecurityUpdateAfterRepairSave('ai')).toBe(false);
expect(shouldRetrySecurityUpdateAfterRepairSave(null)).toBe(false);
});
it('does not force a new focus pulse when the details modal is already open and only the round result is refreshing', () => {
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: true,
wasOpen: true,
})).toBe(false);
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: true,
wasOpen: false,
})).toBe(true);
expect(shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: false,
wasOpen: true,
})).toBe(false);
});
});

View File

@@ -113,6 +113,14 @@ export const shouldReopenSecurityUpdateDetails = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection' || repairSource === 'proxy' || repairSource === 'ai';
export const shouldRefreshSecurityUpdateDetailsFocus = ({
requestedOpen,
wasOpen,
}: {
requestedOpen: boolean;
wasOpen: boolean;
}): boolean => requestedOpen && !wasOpen;
export const shouldRetrySecurityUpdateAfterRepairSave = (
repairSource: SecurityUpdateRepairSource | null | undefined,
): boolean => repairSource === 'connection';

View File

@@ -62,6 +62,17 @@ describe('securityUpdateVisuals', () => {
});
});
it('can scale shell surface alpha with the current appearance opacity so reminder layers stay visually consistent', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const fadedShell = getSecurityUpdateShellSurfaceStyle(lightTheme, 0.5);
const fadedBanner = getSecurityUpdateBannerSurfaceStyle(lightTheme, 0.5);
expect(fadedShell.background).not.toBe(lightTheme.shellBg);
expect(fadedShell.border).not.toBe(lightTheme.shellBorder);
expect(fadedShell.background).toContain('0.49');
expect(fadedBanner.background).toContain('0.49');
});
it('can emphasize a section surface for transient focus and recent-result highlighting', () => {
const lightTheme = buildOverlayWorkbenchTheme(false);
const darkTheme = buildOverlayWorkbenchTheme(true);

View File

@@ -10,6 +10,25 @@ export const SECURITY_UPDATE_RESULT_CARD_ACTIVE_CLASS = 'security-update-result-
type SecurityUpdateSectionSurfaceOptions = {
emphasized?: boolean;
surfaceOpacity?: number;
};
const clampOpacity = (value: number): number => Math.min(1, Math.max(0.1, value));
const formatAlpha = (value: number): string => (
Number(value.toFixed(3)).toString()
);
const applySurfaceOpacity = (token: string, surfaceOpacity = 1): string => {
const normalizedOpacity = clampOpacity(surfaceOpacity);
if (normalizedOpacity >= 0.999) {
return token;
}
return token.replace(
/rgba\(\s*([^)]+?)\s*,\s*([0-9]*\.?[0-9]+)\s*\)/g,
(_, channels: string, alpha: string) => `rgba(${channels}, ${formatAlpha(Number(alpha) * normalizedOpacity)})`,
);
};
const getSecurityUpdateHighlightBorder = (overlayTheme: OverlayWorkbenchTheme): string => (
@@ -40,17 +59,19 @@ export const getSecurityUpdateActionButtonStyle = (): CSSProperties => ({
export const getSecurityUpdateShellSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity = 1,
): CSSProperties => ({
border: overlayTheme.shellBorder,
background: overlayTheme.shellBg,
boxShadow: overlayTheme.shellShadow,
border: applySurfaceOpacity(overlayTheme.shellBorder, surfaceOpacity),
background: applySurfaceOpacity(overlayTheme.shellBg, surfaceOpacity),
boxShadow: applySurfaceOpacity(overlayTheme.shellShadow, surfaceOpacity),
backdropFilter: overlayTheme.shellBackdropFilter,
});
export const getSecurityUpdateBannerSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
surfaceOpacity = 1,
): CSSProperties => ({
...getSecurityUpdateShellSurfaceStyle(overlayTheme),
...getSecurityUpdateShellSurfaceStyle(overlayTheme, surfaceOpacity),
boxShadow: 'none',
});
@@ -58,8 +79,16 @@ export const getSecurityUpdateSectionSurfaceStyle = (
overlayTheme: OverlayWorkbenchTheme,
options: SecurityUpdateSectionSurfaceOptions = {},
): CSSProperties => ({
border: options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
background: options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
boxShadow: options.emphasized ? getSecurityUpdateHighlightShadow(overlayTheme) : 'none',
border: applySurfaceOpacity(
options.emphasized ? getSecurityUpdateHighlightBorder(overlayTheme) : overlayTheme.sectionBorder,
options.surfaceOpacity,
),
background: applySurfaceOpacity(
options.emphasized ? getSecurityUpdateHighlightBackground(overlayTheme) : overlayTheme.sectionBg,
options.surfaceOpacity,
),
boxShadow: options.emphasized
? applySurfaceOpacity(getSecurityUpdateHighlightShadow(overlayTheme), options.surfaceOpacity)
: 'none',
transition: 'background 180ms ease, border-color 180ms ease, box-shadow 180ms ease',
});