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 改善:追蹤分析狀態,避免輸入和結果不匹配 // UX 改善:追蹤分析狀態,避免輸入和結果不匹配
const [lastAnalyzedText, setLastAnalyzedText] = useState('') const [lastAnalyzedText, setLastAnalyzedText] = useState('')
// localStorage 快取函數 // 追蹤本次會話已保存的詞彙(防重複保存)
const saveAnalysisToCache = useCallback((cacheData: any) => { const [savedWordsInSession, setSavedWordsInSession] = useState<Set<string>>(new Set())
try {
localStorage.setItem('generate_analysis_cache', JSON.stringify(cacheData)) // 獲取當前用戶 ID 用於快取隔離
} catch (error) { const getCurrentUserId = useCallback(() => {
console.warn('無法保存分析快取:', error) const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null
} if (!token) return null
}, [])
const loadAnalysisFromCache = useCallback(() => {
try { try {
const cached = localStorage.getItem('generate_analysis_cache') // 解析 JWT token 獲取用戶 ID
return cached ? JSON.parse(cached) : null const payload = JSON.parse(atob(token.split('.')[1]))
} catch (error) { return payload.nameid || payload.sub || null
console.warn('無法載入分析快取:', error) } catch {
return null 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(() => { const clearAnalysisCache = useCallback(() => {
try { try {
localStorage.removeItem('generate_analysis_cache') const userId = getCurrentUserId()
if (!userId) return
const cacheKey = `generate_analysis_cache_${userId}`
localStorage.removeItem(cacheKey)
} catch (error) { } catch (error) {
console.warn('無法清除分析快取:', error) console.warn('無法清除分析快取:', error)
} }
}, []) }, [getCurrentUserId])
// 組件載入時恢復快取的分析結果 // 組件載入時恢復快取的分析結果
useEffect(() => { useEffect(() => {
@ -84,6 +134,12 @@ function GenerateContent() {
setSentenceAnalysis(cached.sentenceAnalysis || null) setSentenceAnalysis(cached.sentenceAnalysis || null)
setSentenceMeaning(cached.sentenceMeaning || '') setSentenceMeaning(cached.sentenceMeaning || '')
setGrammarCorrection(cached.grammarCorrection || null) setGrammarCorrection(cached.grammarCorrection || null)
// 恢復已保存詞彙的狀態
if (cached.savedWordsInSession) {
setSavedWordsInSession(new Set(cached.savedWordsInSession))
}
console.log('✅ 已恢復快取的分析結果') console.log('✅ 已恢復快取的分析結果')
} }
}, [loadAnalysisFromCache]) }, [loadAnalysisFromCache])
@ -94,6 +150,9 @@ function GenerateContent() {
// 清除舊的分析快取 // 清除舊的分析快取
clearAnalysisCache() clearAnalysisCache()
// 重置本次會話的已保存詞彙追蹤
setSavedWordsInSession(new Set())
setIsAnalyzing(true) setIsAnalyzing(true)
try { try {
@ -154,7 +213,7 @@ function GenerateContent() {
}) })
} }
// 保存分析結果到快取 // 保存分析結果到快取(包含已保存詞彙狀態)
const cacheData = { const cacheData = {
textInput, textInput,
sentenceAnalysis: analysisData, sentenceAnalysis: analysisData,
@ -165,7 +224,8 @@ function GenerateContent() {
correctedText: apiData.grammarCorrection.correctedText || textInput, correctedText: apiData.grammarCorrection.correctedText || textInput,
corrections: apiData.grammarCorrection.corrections || [], corrections: apiData.grammarCorrection.corrections || [],
confidenceScore: apiData.grammarCorrection.confidenceScore || 1.0 confidenceScore: apiData.grammarCorrection.confidenceScore || 1.0
} : null } : null,
savedWordsInSession: Array.from(savedWordsInSession)
} }
saveAnalysisToCache(cacheData) saveAnalysisToCache(cacheData)
@ -222,9 +282,21 @@ function GenerateContent() {
} }
}, [sentenceAnalysis, userLevel]) }, [sentenceAnalysis, userLevel])
// 保存詞彙 // 保存詞彙(帶重複檢查)
const handleSaveWord = useCallback(async (word: string, analysis: any) => { const handleSaveWord = useCallback(async (word: string, analysis: any) => {
try { 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 = { const cardData = {
word: word, word: word,
translation: analysis.translation || '', translation: analysis.translation || '',
@ -239,6 +311,9 @@ function GenerateContent() {
const response = await flashcardsService.createFlashcard(cardData) const response = await flashcardsService.createFlashcard(cardData)
if (response.success) { if (response.success) {
// 標記為已保存
setSavedWordsInSession(prev => new Set([...prev, word.toLowerCase()]))
toast.success(`已成功將「${word}」保存到詞卡庫!`) toast.success(`已成功將「${word}」保存到詞卡庫!`)
return { success: true } return { success: true }
} else { } else {
@ -249,7 +324,7 @@ function GenerateContent() {
toast.error(`保存詞卡失敗: ${errorMessage}`) toast.error(`保存詞卡失敗: ${errorMessage}`)
return { success: false, error: errorMessage } return { success: false, error: errorMessage }
} }
}, [toast]) }, [toast, savedWordsInSession])
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <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> return <span key={index} className="text-gray-900">{token}</span>
} }
const isSaved = savedWordsInSession.has(cleanToken.toLowerCase())
return ( return (
<span key={index}> <span key={index}>
<span <span
className={getWordClass(cleanToken, analysis, userLevel)} className={`${getWordClass(cleanToken, analysis, userLevel)} ${
isSaved ? 'relative' : ''
}`}
onClick={() => setSelectedWord(cleanToken)} onClick={() => setSelectedWord(cleanToken)}
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
{token} {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>
</span> </span>
) )