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 改善:追蹤分析狀態,避免輸入和結果不匹配
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue