feat: 實現用戶隔離和重複保存告警機制

重大改進:
- 修復用戶隔離快取機制,使用用戶 ID 區分快取 key
- 添加重複保存告警,防止同一次分析中重複保存相同詞彙
- 實現 JWT token 解析獲取用戶 ID,確保資料隔離
- 添加 24 小時快取過期機制

用戶體驗優化:
- 已保存詞彙顯示綠色勾號視覺標記
- 重複保存時提供確認對話框和清楚說明
- 新分析時自動重置已保存詞彙追蹤
- 完整的快取驗證和安全檢查

隱私保護:
- 不同用戶的分析結果完全隔離
- 換帳號後無法看到其他用戶的快取內容

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-09 22:50:49 +08:00
parent a0bda28c41
commit 69b85c542c
1 changed files with 105 additions and 20 deletions

View File

@ -48,32 +48,82 @@ function GenerateContent() {
// UX 改善:追蹤分析狀態,避免輸入和結果不匹配
const [lastAnalyzedText, setLastAnalyzedText] = useState('')
// localStorage 快取函數
const saveAnalysisToCache = useCallback((cacheData: any) => {
try {
localStorage.setItem('generate_analysis_cache', JSON.stringify(cacheData))
} catch (error) {
console.warn('無法保存分析快取:', error)
}
}, [])
// 追蹤本次會話已保存的詞彙(防重複保存)
const [savedWordsInSession, setSavedWordsInSession] = useState<Set<string>>(new Set())
// 獲取當前用戶 ID 用於快取隔離
const getCurrentUserId = useCallback(() => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null
if (!token) return null
const loadAnalysisFromCache = useCallback(() => {
try {
const cached = localStorage.getItem('generate_analysis_cache')
return cached ? JSON.parse(cached) : null
} catch (error) {
console.warn('無法載入分析快取:', error)
// 解析 JWT token 獲取用戶 ID
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.nameid || payload.sub || null
} catch {
return null
}
}, [])
// localStorage 快取函數(用戶隔離)
const saveAnalysisToCache = useCallback((cacheData: any) => {
try {
const userId = getCurrentUserId()
if (!userId) return
const cacheKey = `generate_analysis_cache_${userId}`
localStorage.setItem(cacheKey, JSON.stringify({
...cacheData,
userId,
timestamp: Date.now()
}))
} catch (error) {
console.warn('無法保存分析快取:', error)
}
}, [getCurrentUserId])
const loadAnalysisFromCache = useCallback(() => {
try {
const userId = getCurrentUserId()
if (!userId) return null
const cacheKey = `generate_analysis_cache_${userId}`
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
const cacheData = JSON.parse(cached)
// 驗證快取是否屬於當前用戶
if (cacheData.userId !== userId) {
localStorage.removeItem(cacheKey)
return null
}
// 檢查快取是否過期24小時
const isExpired = Date.now() - cacheData.timestamp > 24 * 60 * 60 * 1000
if (isExpired) {
localStorage.removeItem(cacheKey)
return null
}
return cacheData
} catch (error) {
console.warn('無法載入分析快取:', error)
return null
}
}, [getCurrentUserId])
const clearAnalysisCache = useCallback(() => {
try {
localStorage.removeItem('generate_analysis_cache')
const userId = getCurrentUserId()
if (!userId) return
const cacheKey = `generate_analysis_cache_${userId}`
localStorage.removeItem(cacheKey)
} catch (error) {
console.warn('無法清除分析快取:', error)
}
}, [])
}, [getCurrentUserId])
// 組件載入時恢復快取的分析結果
useEffect(() => {
@ -84,6 +134,12 @@ function GenerateContent() {
setSentenceAnalysis(cached.sentenceAnalysis || null)
setSentenceMeaning(cached.sentenceMeaning || '')
setGrammarCorrection(cached.grammarCorrection || null)
// 恢復已保存詞彙的狀態
if (cached.savedWordsInSession) {
setSavedWordsInSession(new Set(cached.savedWordsInSession))
}
console.log('✅ 已恢復快取的分析結果')
}
}, [loadAnalysisFromCache])
@ -94,6 +150,9 @@ function GenerateContent() {
// 清除舊的分析快取
clearAnalysisCache()
// 重置本次會話的已保存詞彙追蹤
setSavedWordsInSession(new Set())
setIsAnalyzing(true)
try {
@ -154,7 +213,7 @@ function GenerateContent() {
})
}
// 保存分析結果到快取
// 保存分析結果到快取(包含已保存詞彙狀態)
const cacheData = {
textInput,
sentenceAnalysis: analysisData,
@ -165,7 +224,8 @@ function GenerateContent() {
correctedText: apiData.grammarCorrection.correctedText || textInput,
corrections: apiData.grammarCorrection.corrections || [],
confidenceScore: apiData.grammarCorrection.confidenceScore || 1.0
} : null
} : null,
savedWordsInSession: Array.from(savedWordsInSession)
}
saveAnalysisToCache(cacheData)
@ -222,9 +282,21 @@ function GenerateContent() {
}
}, [sentenceAnalysis, userLevel])
// 保存詞彙
// 保存詞彙(帶重複檢查)
const handleSaveWord = useCallback(async (word: string, analysis: any) => {
try {
// 檢查是否已在本次會話中保存過
if (savedWordsInSession.has(word.toLowerCase())) {
const confirmSave = window.confirm(
`詞彙「${word}」已在本次分析中保存過,確定要重複保存嗎?\n\n重複保存會創建完全相同的詞卡。`
)
if (!confirmSave) {
toast.info(`已取消保存「${word}`)
return { success: false, cancelled: true }
}
}
const cardData = {
word: word,
translation: analysis.translation || '',
@ -239,6 +311,9 @@ function GenerateContent() {
const response = await flashcardsService.createFlashcard(cardData)
if (response.success) {
// 標記為已保存
setSavedWordsInSession(prev => new Set([...prev, word.toLowerCase()]))
toast.success(`已成功將「${word}」保存到詞卡庫!`)
return { success: true }
} else {
@ -249,7 +324,7 @@ function GenerateContent() {
toast.error(`保存詞卡失敗: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}, [toast])
}, [toast, savedWordsInSession])
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
@ -400,15 +475,25 @@ function GenerateContent() {
return <span key={index} className="text-gray-900">{token}</span>
}
const isSaved = savedWordsInSession.has(cleanToken.toLowerCase())
return (
<span key={index}>
<span
className={getWordClass(cleanToken, analysis, userLevel)}
className={`${getWordClass(cleanToken, analysis, userLevel)} ${
isSaved ? 'relative' : ''
}`}
onClick={() => setSelectedWord(cleanToken)}
role="button"
tabIndex={0}
>
{token}
{isSaved && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border border-white"
title="已保存到詞卡庫">
<span className="absolute inset-0 flex items-center justify-center text-white text-xs"></span>
</span>
)}
</span>
</span>
)