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:
parent
a0bda28c41
commit
69b85c542c
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue