From 31a2cdc77eafa822813aee1bc71420d49fd7c5a8 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 20 Apr 2026 10:27:17 +0800 Subject: [PATCH] =?UTF-8?q?2026-04-20-10-14-09=20-=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=9AXOR=E2=86=92AES-GCM=E3=80=81API=20Ke?= =?UTF-8?q?y=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E5=8C=96=E3=80=81?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=A0=81SHA-256=E5=93=88=E5=B8=8C=E3=80=81?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E7=A1=AC=E7=BC=96=E7=A0=81=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 19 +++ src/main.tsx | 20 ++- src/pages/UserManage.tsx | 85 ++++++++--- src/utils/storage.ts | 133 ++++++++++++++--- vite.config.ts | 7 +- 工程分析/实现方案-2026-04-20-10-14-09.md | 177 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-20-10-14-09.md | 96 ++++++++++++ 工程分析/经验记录.md | 42 +++++- 工程分析/需求分析-2026-04-20-10-14-09.md | 72 +++++++++ 9 files changed, 608 insertions(+), 43 deletions(-) create mode 100644 工程分析/实现方案-2026-04-20-10-14-09.md create mode 100644 工程分析/测试方案-2026-04-20-10-14-09.md create mode 100644 工程分析/需求分析-2026-04-20-10-14-09.md diff --git a/.env.example b/.env.example index 7a550fe..7b8da53 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,22 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY" # AI Studio automatically injects this at runtime with the Cloud Run service URL. # Used for self-referential links, OAuth callbacks, and API endpoints. APP_URL="MY_APP_URL" + +# STORAGE_KEY: AES-GCM encryption key for localStorage systemSettings (max 32 chars used). +# Must be set at build time. Leaving it empty will cause runtime initialization error. +STORAGE_KEY="YourRandom32CharKeyHere!!!" + +# DEFAULT_API_KEY: Default AI provider API key, replaces the hardcoded API_KEY_CODES array. +# Must be set at build time. +DEFAULT_API_KEY="your-api-key-here" + +# ADMIN_AUTH_HASH: SHA-256 hex hash of (authorization_password + ADMIN_AUTH_SALT). +# Used in UserManage.tsx to verify high-privilege disable operations. +ADMIN_AUTH_HASH="your-sha256-hash-here" + +# ADMIN_AUTH_SALT: Salt used when computing the admin authorization SHA-256 hash. +ADMIN_AUTH_SALT="your-random-salt-here" + +# LEGACY_STORAGE_KEY: Former XOR encryption key used to auto-migrate old localStorage data. +# Set to the previous hardcoded value if you need backward compatibility. +LEGACY_STORAGE_KEY="MedicalReportSys2024" diff --git a/src/main.tsx b/src/main.tsx index 080dac3..c5b4969 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,19 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; import App from './App.tsx'; import './index.css'; +import { storage } from './utils/storage'; -createRoot(document.getElementById('root')!).render( - - - , -); +async function bootstrap() { + try { + await storage.init(); + } catch (e) { + console.error('Storage initialization failed:', e); + } + createRoot(document.getElementById('root')!).render( + + + , + ); +} + +bootstrap(); diff --git a/src/pages/UserManage.tsx b/src/pages/UserManage.tsx index 71aa3a7..2a2ede0 100644 --- a/src/pages/UserManage.tsx +++ b/src/pages/UserManage.tsx @@ -3,9 +3,10 @@ import { useNavigate } from 'react-router-dom'; import Sidebar from '../components/Sidebar'; import { UserPlus, Edit, Trash2, Upload, X } from 'lucide-react'; import { User, Template } from '../types'; -import { storage } from '../utils/storage'; +import { storage, sha256 } from '../utils/storage'; -const ADMIN_DISABLE_AUTH_KEY = 'DISABLE_ADMIN_2024'; +const ADMIN_AUTH_HASH = (process.env.ADMIN_AUTH_HASH as string) || ''; +const ADMIN_AUTH_SALT = (process.env.ADMIN_AUTH_SALT as string) || ''; export default function UserManage() { const navigate = useNavigate(); @@ -28,6 +29,8 @@ export default function UserManage() { const [confirmPassword, setConfirmPassword] = useState(''); const [authKey, setAuthKey] = useState(''); const [allTemplates, setAllTemplates] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; username: string } | null>(null); useEffect(() => { const user = storage.get('currentUser', null); @@ -102,23 +105,31 @@ export default function UserManage() { const handleDelete = (username: string) => { if (username === 'admin') { - alert('不能删除默认超级管理员'); + setErrorMessage('不能删除默认超级管理员'); return; } if (username === currentUser?.username) { - alert('不能删除当前登录账号'); + setErrorMessage('不能删除当前登录账号'); return; } - if (window.confirm(`确定要删除用户 "${username}" 吗?`)) { - const updated = users.filter(u => u.username !== username); - saveToLocalStorage(updated); - } + setConfirmDialog({ open: true, username }); + }; + + const confirmDelete = () => { + if (!confirmDialog) return; + const updated = users.filter(u => u.username !== confirmDialog.username); + saveToLocalStorage(updated); + setConfirmDialog(null); + }; + + const cancelDelete = () => { + setConfirmDialog(null); }; const handleEdit = (user: User) => { if (currentUser?.role === 'admin') { if ((user.role !== 'user' && user.username !== currentUser.username) || user.department !== currentUser.department) { - alert('您只能管理同部门的医生或您自己'); + setErrorMessage('您只能管理同部门的医生或您自己'); return; } } @@ -150,11 +161,13 @@ export default function UserManage() { }); setConfirmPassword(''); setAuthKey(''); + setErrorMessage(''); setIsModalOpen(true); }; const handleAdd = () => { setIsEditing(false); + setErrorMessage(''); const defaultDept = currentUser?.role === 'admin' ? currentUser.department || '' : ''; const defaultRole = 'user'; let defaultVisible: string[] = []; @@ -182,26 +195,27 @@ export default function UserManage() { setIsModalOpen(true); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setErrorMessage(''); try { if (!formData.username) { - alert('用户ID不能为空'); + setErrorMessage('用户ID不能为空'); return; } if (!isEditing && formData.role === 'super') { - alert('系统中只能存在一个超级管理员,无法新增'); + setErrorMessage('系统中只能存在一个超级管理员,无法新增'); return; } if (!isEditing && users.find(u => u.username === formData.username)) { - alert('用户ID已存在'); + setErrorMessage('用户ID已存在'); return; } if (isEditing && formData.password && formData.password !== confirmPassword) { - alert('两次输入的密码不一致'); + setErrorMessage('两次输入的密码不一致'); return; } @@ -215,7 +229,7 @@ export default function UserManage() { if (finalRole === 'admin') { const existingAdmin = users.find(u => u.role === 'admin' && u.department === finalDepartment && u.username !== formData.username); if (existingAdmin) { - alert('该部门已存在管理员,一个部门只能有一个管理员'); + setErrorMessage('该部门已存在管理员,一个部门只能有一个管理员'); return; } } @@ -223,14 +237,15 @@ export default function UserManage() { if (finalRole === 'user') { const hasAdminInDept = users.some(u => u.role === 'admin' && u.department === finalDepartment); if (!hasAdminInDept) { - alert('该部门暂无管理员,请先建立一个部门管理员再创建医生'); + setErrorMessage('该部门暂无管理员,请先建立一个部门管理员再创建医生'); return; } } if (finalRole !== 'user' && formData.status === 'inactive') { - if (authKey.trim() !== ADMIN_DISABLE_AUTH_KEY) { - alert('禁用管理员账号需要输入正确的授权密钥'); + const inputHash = await sha256(authKey.trim() + ADMIN_AUTH_SALT); + if (inputHash !== ADMIN_AUTH_HASH) { + setErrorMessage('禁用管理员账号需要输入正确的授权密钥'); return; } } @@ -318,7 +333,7 @@ export default function UserManage() { } setIsModalOpen(false); } catch (err: any) { - alert('保存失败: ' + (err?.message || String(err))); + setErrorMessage('保存失败: ' + (err?.message || String(err))); console.error(err); } }; @@ -485,6 +500,11 @@ export default function UserManage() {

{isEditing ? '编辑用户' : '新增用户'}

填写用户信息并分配系统权限。

+ {errorMessage && ( +
+ {errorMessage} +
+ )}
@@ -736,6 +756,33 @@ export default function UserManage() {
)} + + {confirmDialog && ( +
+
+

确认删除

+

+ 确定要删除用户 "{confirmDialog.username}" 吗?此操作不可撤销。 +

+
+ + +
+
+
+ )}
); } diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 78bfe40..a67bdbf 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,11 +1,10 @@ -// API Key 以字符码形式存储,避免源码中直接出现明文字符串 -const API_KEY_CODES = [115, 107, 45, 50, 73, 65, 70, 110, 56, 79, 82, 111, 83, 100, 85, 99, 67, 120, 89, 88, 54, 68, 109, 88, 74, 87, 98, 72, 55, 66, 120, 102, 116, 83, 83, 65, 56, 107, 78, 56, 56, 109, 68, 49, 75, 85, 68, 84, 109, 107, 118]; - +// 默认 API Key 从构建环境变量读取,彻底移除前端硬编码字符码数组 export function getDefaultApiKey(): string { - return String.fromCharCode(...API_KEY_CODES); + return (process.env.DEFAULT_API_KEY as string) || ''; } -const CRYPTO_KEY = 'MedicalReportSys2024'; +// 旧 XOR 密钥改为从环境变量读取,构建产物中不再硬编码 +const LEGACY_CRYPTO_KEY = (process.env.LEGACY_STORAGE_KEY as string) || ''; function xorEncrypt(text: string, key: string): string { let result = ''; @@ -24,18 +23,114 @@ function xorDecrypt(encrypted: string, key: string): string { return result; } +// ---------- Web Crypto API AES-GCM 实现 ---------- +const AES_GCM_PREFIX = 'ag:'; + +let cryptoKey: CryptoKey | null = null; +let systemSettingsCache: any = null; + +async function importCryptoKey(): Promise { + const raw = (process.env.STORAGE_KEY as string) || ''; + if (!raw) throw new Error('STORAGE_KEY not configured in build env'); + const encoder = new TextEncoder(); + const keyData = encoder.encode(raw.padEnd(32, '0').slice(0, 32)); + return crypto.subtle.importKey( + 'raw', + keyData, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +async function aesEncrypt(plaintext: string): Promise { + if (!cryptoKey) throw new Error('Crypto not initialized'); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoder = new TextEncoder(); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + encoder.encode(plaintext) + ); + return `${AES_GCM_PREFIX}${arrayBufferToBase64(iv)}:${arrayBufferToBase64(ciphertext)}`; +} + +async function aesDecrypt(combined: string): Promise { + if (!cryptoKey) throw new Error('Crypto not initialized'); + const [ivB64, cipherB64] = combined.split(':'); + const iv = base64ToArrayBuffer(ivB64); + const ciphertext = base64ToArrayBuffer(cipherB64); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: new Uint8Array(iv) }, + cryptoKey, + ciphertext + ); + const decoder = new TextDecoder(); + return JSON.parse(decoder.decode(decrypted)); +} + +async function persistSystemSettings(): Promise { + if (!cryptoKey || systemSettingsCache === null) return; + const encrypted = await aesEncrypt(JSON.stringify(systemSettingsCache)); + localStorage.setItem('systemSettings', encrypted); +} + +export async function sha256(message: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +async function initStorage(): Promise { + cryptoKey = await importCryptoKey(); + const raw = localStorage.getItem('systemSettings'); + if (raw) { + if (raw.startsWith(AES_GCM_PREFIX)) { + systemSettingsCache = await aesDecrypt(raw.slice(AES_GCM_PREFIX.length)); + } else { + try { + systemSettingsCache = JSON.parse(raw); + } catch { + systemSettingsCache = JSON.parse(xorDecrypt(raw, LEGACY_CRYPTO_KEY)); + } + // 静默迁移到新格式 + await persistSystemSettings(); + } + } +} + export const storage = { + async init(): Promise { + await initStorage(); + }, + get(key: string, fallback: T): T { + if (key === 'systemSettings') { + return systemSettingsCache !== null ? systemSettingsCache : fallback; + } try { const raw = localStorage.getItem(key); if (!raw) return fallback; - if (key === 'systemSettings') { - try { - return JSON.parse(raw) as T; - } catch { - return JSON.parse(xorDecrypt(raw, CRYPTO_KEY)) as T; - } - } return JSON.parse(raw) as T; } catch { return fallback; @@ -43,18 +138,22 @@ export const storage = { }, set(key: string, value: T): void { + if (key === 'systemSettings') { + systemSettingsCache = value; + persistSystemSettings().catch(e => console.error('systemSettings persist failed:', e)); + return; + } try { - let data = JSON.stringify(value); - if (key === 'systemSettings') { - data = xorEncrypt(data, CRYPTO_KEY); - } - localStorage.setItem(key, data); + localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.error('Storage save failed (possibly quota exceeded):', e); } }, remove(key: string): void { + if (key === 'systemSettings') { + systemSettingsCache = null; + } localStorage.removeItem(key); }, diff --git a/vite.config.ts b/vite.config.ts index 0506f1b..e2464e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,11 @@ export default defineConfig(({mode}) => { plugins: [react(), tailwindcss()], define: { 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.STORAGE_KEY': JSON.stringify(env.STORAGE_KEY), + 'process.env.DEFAULT_API_KEY': JSON.stringify(env.DEFAULT_API_KEY), + 'process.env.ADMIN_AUTH_HASH': JSON.stringify(env.ADMIN_AUTH_HASH), + 'process.env.ADMIN_AUTH_SALT': JSON.stringify(env.ADMIN_AUTH_SALT), + 'process.env.LEGACY_STORAGE_KEY': JSON.stringify(env.LEGACY_STORAGE_KEY), }, resolve: { alias: { @@ -17,7 +22,7 @@ export default defineConfig(({mode}) => { }, server: { // HMR is disabled in AI Studio via DISABLE_HMR env var. - // Do not modify—file watching is disabled to prevent flickering during agent edits. + // Do not modify—file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true', }, }; diff --git a/工程分析/实现方案-2026-04-20-10-14-09.md b/工程分析/实现方案-2026-04-20-10-14-09.md new file mode 100644 index 0000000..705793c --- /dev/null +++ b/工程分析/实现方案-2026-04-20-10-14-09.md @@ -0,0 +1,177 @@ +# 实现方案 + +> 时间戳:2026-04-20-10-14-09 +> 对应需求分析:`需求分析-2026-04-20-10-14-09.md` + +--- + +## 1. 方案概述 + +在不改变 `storage.get/set` 同步调用签名的前提下,将 `systemSettings` 的底层加密从 XOR 迁移至 Web Crypto API AES-GCM,密钥与 API Key、授权哈希全部移入构建阶段环境变量。应用启动时异步初始化存储模块,预解密 `systemSettings` 到内存缓存,后续同步读写均走缓存。旧 XOR 密文自动识别并迁移。UserManage.tsx 中的明文授权常量替换为 SHA-256 环境变量哈希比对,同时以最小代价替换原生 alert/confirm 为内联状态提示。 + +--- + +## 2. 详细改动清单 + +### 2.1 文件:src/utils/storage.ts + +| 行号范围 | 改动类型 | 说明 | +|----------|----------|------| +| 1-6 | 删除 | 移除 `API_KEY_CODES` 数组与 `getDefaultApiKey()` 的旧实现 | +| 8 | 修改 | `CRYPTO_KEY` 改为从 `process.env.STORAGE_KEY` 读取,并更名为 `LEGACY_CRYPTO_KEY` | +| 10-25 | 保留 | 旧 `xorEncrypt` / `xorDecrypt` 保留用于向后兼容读取 | +| 新增 | 新增 | 引入 Web Crypto API:`importCryptoKey`、`aesEncrypt`、`aesDecrypt` | +| 新增 | 新增 | `AES_GCM_PREFIX = 'ag:'` 用于识别新格式密文 | +| 新增 | 新增 | `systemSettingsCache`、`initPromise` 等模块级状态 | +| 新增 | 新增 | 异步 `init()` 函数:导入密钥、预加载 systemSettings、自动迁移旧数据 | +| 27-43 | 修改 | `get()` 对 `systemSettings` 改为从内存缓存返回 | +| 45-55 | 修改 | `set()` 对 `systemSettings` 改为更新缓存并触发异步 `persistSystemSettings()` | +| 新增 | 新增 | `sha256(text)` 工具函数(Web Crypto API)供 UserManage.tsx 使用 | + +**关键代码逻辑**: +```typescript +// AES-GCM 密文格式: ag:: +const AES_GCM_PREFIX = 'ag:'; + +async function init() { + const keyData = process.env.STORAGE_KEY || ''; + if (!keyData) throw new Error('STORAGE_KEY not configured in build env'); + cryptoKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(keyData.padEnd(32, '0').slice(0, 32)), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + const raw = localStorage.getItem('systemSettings'); + if (raw) { + if (raw.startsWith(AES_GCM_PREFIX)) { + systemSettingsCache = await aesDecrypt(raw); + } else { + try { systemSettingsCache = JSON.parse(raw); } + catch { systemSettingsCache = JSON.parse(xorDecrypt(raw, LEGACY_KEY)); } + await persistSystemSettings(); // 静默迁移 + } + } +} +``` + +### 2.2 文件:src/main.tsx + +| 行号范围 | 改动类型 | 说明 | +|----------|----------|------| +| 4-10 | 修改 | 包装为异步 `bootstrap()`,先 `await storage.init()` 再 render App | + +```typescript +import { storage } from './utils/storage'; +async function bootstrap() { + await storage.init(); + createRoot(document.getElementById('root')!).render( + + ); +} +bootstrap(); +``` + +### 2.3 文件:vite.config.ts + +| 行号范围 | 改动类型 | 说明 | +|----------|----------|------| +| 10-12 | 修改 | 在 `define` 中新增 4 个环境变量注入 | + +```typescript +define: { + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.STORAGE_KEY': JSON.stringify(env.STORAGE_KEY), + 'process.env.DEFAULT_API_KEY': JSON.stringify(env.DEFAULT_API_KEY), + 'process.env.ADMIN_AUTH_HASH': JSON.stringify(env.ADMIN_AUTH_HASH), + 'process.env.ADMIN_AUTH_SALT': JSON.stringify(env.ADMIN_AUTH_SALT), +}, +``` + +### 2.4 文件:.env.example + +| 行号范围 | 改动类型 | 说明 | +|----------|----------|------| +| 新增 | 新增 | 追加 4 个必需环境变量模板 | + +```bash +# 本地存储 AES-GCM 加密密钥(建议 32 位随机字符串) +STORAGE_KEY="YourRandom32CharKeyHere!!!" + +# 默认 AI API Key(替代原 API_KEY_CODES 硬编码) +DEFAULT_API_KEY="your-api-key-here" + +# 禁用管理员操作的授权码 SHA-256 哈希值(hex 小写) +ADMIN_AUTH_HASH="your-sha256-hash-here" + +# 授权码哈希盐值 +ADMIN_AUTH_SALT="your-random-salt-here" +``` + +### 2.5 文件:src/pages/UserManage.tsx + +| 行号范围 | 改动类型 | 说明 | +|----------|----------|------| +| 8 | 删除 | 移除 `ADMIN_DISABLE_AUTH_KEY = 'DISABLE_ADMIN_2024'` | +| 新增 | 新增 | 从环境变量读取 `ADMIN_AUTH_HASH` 和 `ADMIN_AUTH_SALT` | +| 29 | 修改 | `authKey` 保持,但比对逻辑改为 SHA-256 | +| 新增 | 新增 | 增加 `errorMessage`、`confirmDialog` 状态替换 alert/confirm | +| 103-116 | 修改 | `handleDelete` 改为触发确认对话框状态,移除 `window.confirm` | +| 118-154 | 修改 | `handleEdit` 中 alert 改为设置 `errorMessage` | +| 185-324 | 修改 | `handleSubmit` 改为 async,所有 alert 改为 `errorMessage`,授权比对改为 `await sha256(authKey + salt)` | +| 新增 JSX | 新增 | 表单顶部增加错误提示条;新增轻量确认对话框覆盖层 | + +--- + +## 3. 关键设计决策 + +| 决策点 | 选择方案 | 备选方案 | 不选备选的原因 | +|--------|----------|----------|----------------| +| systemSettings 异步加密的同步接口兼容 | 模块初始化时预解密到内存缓存,get/set 保持同步 | 将 storage.get/set 全局改为 async | 调用点超过 50 处,改动面过大,风险不可控 | +| AES-GCM 密钥长度处理 | 将环境变量字符串 padEnd 到 32 字节后截断 | 要求用户必须提供 32 字节 | 提升用户体验,减少配置门槛 | +| 旧数据迁移时机 | init() 中自动检测并静默迁移 | 提供手动迁移脚本 | 纯前端无脚本执行环境,自动迁移对用户透明 | +| SHA-256 实现选择 | Web Crypto API(已在 storage.ts 提供工具函数) | crypto-js 同步计算 | 统一使用 Web Crypto API,减少额外依赖引用 | +| alert/confirm 替换策略 | 内联状态提示条 + 轻量确认状态 | 抽取全局 Modal 组件 | 项目中无现成 Modal,本次以最小代价消除明文暴露和原生弹窗 | + +--- + +## 4. 与现有规范的兼容性检查 + +- [ ] contentEditable 编辑器规范(5.1) — 本次不涉及编辑器 +- [x] 自动保存与草稿机制(5.2) — storage 接口签名不变,ReportEditor 自动保存逻辑无影响 +- [ ] 图片与视频处理(5.3) — 本次不涉及 +- [ ] 智能字段与动态表单(5.4) — 本次不涉及 +- [ ] AI 功能开发规范(5.5) — `getDefaultApiKey()` 返回值不变,调用方无感知 +- [x] 数据迁移与类型安全(5.6) — 旧 XOR 密文自动迁移,使用 `(obj as any).oldField` 安全访问思路已体现在旧格式兼容分支 +- [x] UI/UX 通用规范(5.7) — UserManage.tsx 中原生 alert/confirm 全部替换为自定义状态提示 + +--- + +## 5. 回滚策略 + +若构建失败或运行异常: + +```bash +git reset --hard HEAD~1 +``` + +或手动还原: +1. 恢复 `src/utils/storage.ts` 中的 XOR 逻辑与 `API_KEY_CODES` +2. 恢复 `src/pages/UserManage.tsx` 中的 `ADMIN_DISABLE_AUTH_KEY` +3. 恢复 `src/main.tsx` 为同步 render +4. 还原 `vite.config.ts` 的 define 配置 + +--- + +## 6. 预估工作量 + +| 阶段 | 预估时间 | +|------|----------| +| 代码修改 | 25 min | +| 测试验证 | 15 min | +| 文档更新 | 10 min | + +--- + +**状态**:✅ 用户已豁免人工审核确认 diff --git a/工程分析/测试方案-2026-04-20-10-14-09.md b/工程分析/测试方案-2026-04-20-10-14-09.md new file mode 100644 index 0000000..f9b5fe4 --- /dev/null +++ b/工程分析/测试方案-2026-04-20-10-14-09.md @@ -0,0 +1,96 @@ +# 测试方案 + +> 时间戳:2026-04-20-10-14-09 +> 对应需求分析:`需求分析-2026-04-20-10-14-09.md` +> 对应实现方案:`实现方案-2026-04-20-10-14-09.md` + +--- + +## 1. 测试环境 + +- 浏览器:Chrome / Edge(本地验证) +- Node 版本:v24.14.1 +- 是否使用默认测试数据:是(通过 Login.tsx 种子数据) +- 环境变量配置(`.env.local` 或系统环境变量): + ``` + STORAGE_KEY="TestStorageKeyForDev2024!!!" + DEFAULT_API_KEY="sk-test-api-key-123456789" + ADMIN_AUTH_HASH="5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + ADMIN_AUTH_SALT="static-salt-001" + ``` + > 注:上述 HASH 是 `"password" + "static-salt-001"` 的 SHA-256,测试时输入 `password` 应可通过授权。 + +--- + +## 2. 功能测试用例 + +| 用例ID | 测试场景 | 操作步骤 | 预期结果 | 实际结果 | 是否通过 | +|--------|----------|----------|----------|----------|----------| +| TC-01 | AES-GCM 新数据加密写入 | 1. 登录后进入系统设置
2. 修改任意配置并保存 | localStorage 中 `systemSettings` 值以 `ag:` 开头 | | □ | +| TC-02 | AES-GCM 新数据解密读取 | 1. 刷新页面
2. 进入报告编辑器
3. 查看 AI 配置或抽帧配置 | 正确读取到之前保存的系统设置,无解密报错 | | □ | +| TC-03 | 旧 XOR 数据向后兼容 | 1. 先回退代码,用旧版写入一条 systemSettings
2. 切换回新代码刷新页面 | 旧 XOR 密文被正确识别、解密并静默迁移为 AES-GCM 格式 | | □ | +| TC-04 | 默认 API Key 来源 | 1. 打开浏览器控制台
2. 执行 `getDefaultApiKey()` | 返回值为 `.env` 中配置的 `DEFAULT_API_KEY`,非硬编码字符码数组还原值 | | □ | +| TC-05 | 管理员禁用授权正确 | 1. 超级管理员编辑任一管理员
2. 状态改为"禁用"
3. 授权码输入 `password` | 保存成功 | | □ | +| TC-06 | 管理员禁用授权错误 | 1. 同 TC-05 场景
2. 授权码输入 `wrong` | 表单顶部显示错误提示"授权密钥不正确",保存被阻止 | | □ | +| TC-07 | alert/confirm 已移除 | 1. 尝试删除用户
2. 触发各类表单校验错误 | 不再出现浏览器原生 alert/confirm,全部以内联提示或自定义确认层呈现 | | □ | + +--- + +## 3. 边界测试 + +| 场景 | 测试内容 | 预期表现 | +|------|----------|----------| +| 空数据 | 首次访问(localStorage 无 systemSettings) | `storage.init()` 正常完成,缓存为 null,后续 get 返回 fallback | +| 大数据量 | systemSettings 包含大量模板和配置 | AES-GCM 加密后 Base64 长度可控,localStorage 5MB 限额不溢出 | +| 异常输入 | 用户输入空授权码提交 | 校验不通过,提示授权密钥不能为空(或错误) | +| 环境变量缺失 | 构建时未设置 `STORAGE_KEY` | 应用启动时 `storage.init()` 抛出可读错误,控制台可见 | + +--- + +## 4. 回归测试范围 + +(验证已有功能未被破坏) + +- [ ] 登录流程(admin / manager / doctor) +- [ ] 报告编辑器自动保存与草稿恢复 +- [ ] 模板管理增删改查 +- [ ] 系统设置页面配置保存与读取 +- [ ] AI 功能调用(需配置有效 API Key 验证) +- [ ] 用户管理增删改查(非禁用场景) + +--- + +## 5. 类型检查 + +```bash +npm run lint +``` + +- 预期结果:`tsc --noEmit` 无错误,无 TS2345 / TS2554 等类型不匹配 + +--- + +## 6. 构建验证 + +```bash +npm run build +``` + +- 预期结果:构建成功,无报错,产物输出至 `dist/` +- 额外检查:构建产物中搜索 `"MedicalReportSys2024"`、`"DISABLE_ADMIN_2024"`、`API_KEY_CODES` 等敏感字符串,应无命中 + +--- + +## 7. 测试结论 + +| 项目 | 结果 | +|------|------| +| 功能测试 | □ 通过 / □ 部分通过 / □ 未通过 | +| 类型检查 | □ 通过 / □ 未通过 | +| 构建验证 | □ 通过 / □ 未通过 | +| 安全扫描(构建产物无硬编码密钥) | □ 通过 / □ 未通过 | +| 整体结论 | □ 可交付 / □ 需修复后复测 | + +--- + +**状态**:✅ 用户已豁免人工审核确认 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index e2e8fd7..9c821a9 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -28,5 +28,45 @@ ## 历史记录 -(以下为实际踩坑记录,按时间倒序排列) +### [2026-04-20-10-14-09] Web Crypto API 异步性与 storage 同步接口冲突 +### A. 具体问题 +需求要求将 `systemSettings` 的加密从 XOR 升级为 Web Crypto API AES-GCM,同时维持 `storage.get/set` 的同步调用签名。但 `crypto.subtle` 的所有加解密操作均为异步 Promise,若直接将 `storage.get/set` 改为 async,将导致 ReportEditor.tsx、Login.tsx 等数十处调用点需要同步改为 await,改动面极大且风险不可控。 + +### B. 产生问题原因 +Web Crypto API 是浏览器原生标准,设计为全异步 API,无法像 crypto-js 那样提供同步调用。而项目早期设计时 `storage` 模块全部为同步接口,未预留异步扩展点。 + +### C. 解决问题方案 +采用"启动时预解密 + 内存缓存"的折中方案: +1. 在 `storage.ts` 中新增异步 `init()` 函数,在应用启动(`main.tsx` 中 `await storage.init()`)时预解密 `systemSettings` 到模块级内存缓存 `systemSettingsCache`。 +2. `storage.get('systemSettings')` 改为从缓存同步返回,`storage.set('systemSettings')` 改为更新缓存并触发异步持久化(不阻塞调用方)。 +3. 其他 key 的读写保持原样,完全不受影响。 +4. 该方案无需修改任何业务组件中 `storage.get('systemSettings')` 的调用方式。 + +### D. 后续如何避免问题 +- 若未来再次遇到"必须引入异步存储操作"的需求,优先评估能否用"启动预加载 + 内存缓存"模式规避接口签名变更。 +- 若必须改异步接口,应将影响范围限制在单一 key(如新增 `getAsync/setAsync`),而非全局改动 `storage.get/set`。 +- 在需求分析阶段增加"接口兼容性影响评估"子项,提前识别同步/异步冲突。 + +--- + +### [2026-04-20-10-14-09] 旧数据兼容密钥与"消除硬编码"验收标准冲突 + +### A. 具体问题 +安全加固的验收标准要求"构建产物中不再出现任何硬编码密钥字符串"。但实现 AES-GCM 迁移时,必须保留旧 XOR 密钥 `MedicalReportSys2024` 才能解密历史 localStorage 中的 `systemSettings` 数据。首次修改后构建产物扫描发现该字符串仍然存在。 + +### B. 产生问题原因 +数据迁移需求(兼容旧 XOR 密文)与安全需求(消除硬编码密钥)表面上互斥。根源在于旧密钥是以明文常量形式写死在源码中的,构建产物必然包含该字符串。 + +### C. 解决问题方案 +将旧 XOR 密钥同样移入构建阶段环境变量 `LEGACY_STORAGE_KEY`,通过 `vite.config.ts` 的 `define` 注入: +- 构建产物中不再包含固定的 `"MedicalReportSys2024"`。 +- 若部署方需要兼容旧数据,在构建时配置 `LEGACY_STORAGE_KEY="MedicalReportSys2024"` 即可正常迁移。 +- 若部署方不需要兼容(全新部署),留空即可,旧数据将以 JSON.parse 失败处理,回退到 fallback。 + +### D. 后续如何避免问题 +- 任何涉及历史数据迁移的安全重构,必须在方案设计阶段同步考虑"旧密钥/旧算法常量是否需要保留"。 +- 检查清单:若验收标准包含"构建产物无硬编码敏感字符串",则所有历史兼容所需的常量也必须环境变量化。 +- `.env.example` 中应显式标注 `LEGACY_STORAGE_KEY` 的用途("Former XOR encryption key"),避免运维人员误删。 + +--- diff --git a/工程分析/需求分析-2026-04-20-10-14-09.md b/工程分析/需求分析-2026-04-20-10-14-09.md new file mode 100644 index 0000000..e9998d8 --- /dev/null +++ b/工程分析/需求分析-2026-04-20-10-14-09.md @@ -0,0 +1,72 @@ +# 需求分析 + +> 时间戳:2026-04-20-10-14-09 + +--- + +## 1. 原始需求描述 + +用户提出三个核心安全问题,要求对纯前端架构下的敏感数据保护机制进行加固: + +1. **storage.ts 中 XOR 加密硬编码静态密钥** → 已知明文攻击风险 +2. **API_KEY_CODES 字符码数组混淆** → API 密钥客户端暴露与盗刷风险 +3. **ADMIN_DISABLE_AUTH_KEY 明文硬编码** → 垂直越权漏洞 + +用户给出的妥协方案方向(鉴于纯前端限制): +- 问题1:改用 Web Crypto API (AES-GCM) + 环境变量注入密钥 +- 问题2:剥离前端密钥还原逻辑,至少移入构建阶段环境变量 +- 问题3:用 SHA-256 哈希替代明文常量比对 + +--- + +## 2. 需求拆解 + +| 序号 | 子需求 | 涉及文件 | 优先级 | +|------|--------|----------|--------| +| 1 | 弃用 XOR 加密,改用 Web Crypto API AES-GCM | `src/utils/storage.ts` | P0 | +| 2 | 密钥从硬编码改为构建时环境变量注入 | `vite.config.ts`, `.env.example` | P0 | +| 3 | 兼容旧 XOR 加密数据的自动迁移读取 | `src/utils/storage.ts` | P0 | +| 4 | 删除 API_KEY_CODES 硬编码字符码数组 | `src/utils/storage.ts` | P0 | +| 5 | 默认 API Key 改为从环境变量读取 | `src/utils/storage.ts`, `vite.config.ts` | P0 | +| 6 | ADMIN_DISABLE_AUTH_KEY 改为 SHA-256 哈希比对 | `src/pages/UserManage.tsx` | P0 | +| 7 | 将源码中明文授权码替换为环境变量哈希值 | `.env.example`, `vite.config.ts` | P0 | +| 8 | 修复 UserManage.tsx 中原生 alert/confirm 使用 | `src/pages/UserManage.tsx` | P1 | + +--- + +## 3. 影响范围评估 + +### 3.1 需要修改的文件 +- `src/utils/storage.ts` — 加密机制重构、API Key 来源变更 +- `src/pages/UserManage.tsx` — 授权码哈希比对、alert/confirm 替换 +- `vite.config.ts` — 新增环境变量注入 +- `.env.example` — 新增必需环境变量模板 + +### 3.2 潜在风险点 +- **数据迁移兼容性**:旧版本用户 localStorage 中的 systemSettings 仍用 XOR 加密,必须支持双轨读取(先尝试 AES-GCM 解密,失败再回退 XOR) +- **Web Crypto API 异步性**:storage.get 当前为同步接口,AES-GCM 解密为异步操作,需调整接口或设计异步包装 +- **环境变量缺失导致构建失败**:若用户未配置新环境变量,构建产物中密钥将为空字符串 +- **SHA-256 哈希盐值**:若仅做简单 SHA-256,彩虹表仍可攻击,需引入盐值 + +### 3.3 是否涉及数据迁移 +- [x] 是。旧 `systemSettings` 的 XOR 加密数据需兼容读取,新写入全面使用 AES-GCM。 + +--- + +## 4. 验收标准 + +- [ ] `npm run lint` 无类型错误 +- [ ] `npm run build` 构建成功 +- [ ] storage.ts 中不再出现任何硬编码密钥字符串 +- [ ] UserManage.tsx 中不再出现明文授权常量 +- [ ] 旧版本 XOR 加密的 systemSettings 仍能被正确读取(向后兼容) +- [ ] 新写入的 systemSettings 使用 AES-GCM 加密 +- [ ] 默认 API Key 从环境变量注入,源码中无字符码数组 +- [ ] 禁用管理员的授权校验使用 SHA-256 + 盐值比对 +- [ ] UserManage.tsx 中原生 alert/confirm 全部替换为自定义 UI 或至少移除硬编码明文提示 + +--- + +## 5. 关联历史需求 + +无直接关联历史需求。但需遵守 AGENTS.md 第 5.6 节数据迁移规范、第 5.7 节禁用原生 alert/confirm 规范。