// src/store/slices/stockSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { eventService, stockService } from '../../services/eventService'; import { logger } from '../../utils/logger'; import { getApiBase } from '../../utils/apiConfig'; // ==================== Watchlist 缓存配置 ==================== const WATCHLIST_CACHE_KEY = 'watchlist_cache'; const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天 /** * 从 localStorage 读取自选股缓存 */ const loadWatchlistFromCache = () => { try { const cached = localStorage.getItem(WATCHLIST_CACHE_KEY); if (!cached) return null; const { data, timestamp } = JSON.parse(cached); const now = Date.now(); // 检查缓存是否过期(7天) if (now - timestamp > WATCHLIST_CACHE_DURATION) { localStorage.removeItem(WATCHLIST_CACHE_KEY); logger.debug('stockSlice', '自选股缓存已过期'); return null; } logger.debug('stockSlice', '自选股 localStorage 缓存命中', { count: data?.length || 0, age: Math.round((now - timestamp) / 1000 / 60) + '分钟前' }); return data; } catch (error) { logger.error('stockSlice', 'loadWatchlistFromCache', error); return null; } }; /** * 保存自选股到 localStorage */ const saveWatchlistToCache = (data) => { try { localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() })); logger.debug('stockSlice', '自选股已缓存到 localStorage', { count: data?.length || 0 }); } catch (error) { logger.error('stockSlice', 'saveWatchlistToCache', error); } }; // ==================== Async Thunks ==================== /** * 获取事件相关股票(Redux 缓存) * @param {Object} params * @param {string} params.eventId - 事件ID * @param {boolean} params.forceRefresh - 是否强制刷新 * @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景) */ export const fetchEventStocks = createAsyncThunk( 'stock/fetchEventStocks', async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => { logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh, skipIfNoAccess }); // 检查订阅状态,如果会员过期则跳过请求 if (skipIfNoAccess) { const subscriptionInfo = getState().subscription?.info; const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active; if (isExpired) { logger.debug('stockSlice', '会员已过期,跳过 fetchEventStocks 请求', { eventId }); return { eventId, stocks: [], skipped: true }; } } // Redux 状态缓存 if (!forceRefresh) { const cached = getState().stock.eventStocksCache[eventId]; if (cached && cached.length > 0) { logger.debug('stockSlice', 'Redux 缓存命中', { eventId }); return { eventId, stocks: cached }; } } // API 请求 const res = await eventService.getRelatedStocks(eventId); if (res.success && res.data) { logger.debug('stockSlice', 'API 请求成功', { eventId, stockCount: res.data.length }); return { eventId, stocks: res.data }; } throw new Error(res.error || '获取股票数据失败'); } ); /** * 获取股票行情 */ export const fetchStockQuotes = createAsyncThunk( 'stock/fetchStockQuotes', async ({ codes, eventTime }) => { logger.debug('stockSlice', 'fetchStockQuotes', { codeCount: codes.length, eventTime }); const quotes = await stockService.getQuotes(codes, eventTime); return quotes; } ); /** * 获取事件详情(Redux 缓存) */ export const fetchEventDetail = createAsyncThunk( 'stock/fetchEventDetail', async ({ eventId, forceRefresh = false }, { getState }) => { logger.debug('stockSlice', 'fetchEventDetail', { eventId }); // Redux 缓存 if (!forceRefresh) { const cached = getState().stock.eventDetailsCache[eventId]; if (cached) { logger.debug('stockSlice', 'Redux 缓存命中 - eventDetail', { eventId }); return { eventId, detail: cached }; } } // API 请求 const res = await eventService.getEventDetail(eventId); if (res.success && res.data) { return { eventId, detail: res.data }; } throw new Error(res.error || '获取事件详情失败'); } ); /** * 获取历史事件对比(Redux 缓存) * @param {Object} params * @param {string} params.eventId - 事件ID * @param {boolean} params.forceRefresh - 是否强制刷新 * @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景) */ export const fetchHistoricalEvents = createAsyncThunk( 'stock/fetchHistoricalEvents', async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => { logger.debug('stockSlice', 'fetchHistoricalEvents', { eventId, skipIfNoAccess }); // 检查订阅状态,如果会员过期则跳过请求 if (skipIfNoAccess) { const subscriptionInfo = getState().subscription?.info; const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active; if (isExpired) { logger.debug('stockSlice', '会员已过期,跳过 fetchHistoricalEvents 请求', { eventId }); return { eventId, events: [], skipped: true }; } } // Redux 缓存 if (!forceRefresh) { const cached = getState().stock.historicalEventsCache[eventId]; if (cached) { return { eventId, events: cached }; } } // API 请求 const res = await eventService.getHistoricalEvents(eventId); if (res.success && res.data) { return { eventId, events: res.data }; } return { eventId, events: [] }; } ); /** * 获取传导链分析(Redux 缓存) * @param {Object} params * @param {string} params.eventId - 事件ID * @param {boolean} params.forceRefresh - 是否强制刷新 * @param {boolean} params.skipIfNoAccess - 如果无权限则跳过请求(会员过期场景) */ export const fetchChainAnalysis = createAsyncThunk( 'stock/fetchChainAnalysis', async ({ eventId, forceRefresh = false, skipIfNoAccess = false }, { getState }) => { logger.debug('stockSlice', 'fetchChainAnalysis', { eventId, skipIfNoAccess }); // 检查订阅状态,如果会员过期则跳过请求 if (skipIfNoAccess) { const subscriptionInfo = getState().subscription?.info; const isExpired = subscriptionInfo?.type !== 'free' && !subscriptionInfo?.is_active; if (isExpired) { logger.debug('stockSlice', '会员已过期,跳过 fetchChainAnalysis 请求', { eventId }); return { eventId, analysis: null, skipped: true }; } } // Redux 缓存 if (!forceRefresh) { const cached = getState().stock.chainAnalysisCache[eventId]; if (cached) { return { eventId, analysis: cached }; } } // API 请求 const res = await eventService.getTransmissionChainAnalysis(eventId); if (res.success && res.data) { return { eventId, analysis: res.data }; } return { eventId, analysis: null }; } ); /** * 获取超预期得分 */ export const fetchExpectationScore = createAsyncThunk( 'stock/fetchExpectationScore', async ({ eventId }) => { logger.debug('stockSlice', 'fetchExpectationScore', { eventId }); if (eventService.getExpectationScore) { const res = await eventService.getExpectationScore(eventId); if (res.success && res.data) { return { eventId, score: res.data.score }; } } return { eventId, score: null }; } ); /** * 加载用户自选股列表(包含完整信息) * 缓存策略:Redux 内存缓存 → localStorage 持久缓存(7天) → API 请求 */ export const loadWatchlist = createAsyncThunk( 'stock/loadWatchlist', async (_, { getState }) => { logger.debug('stockSlice', 'loadWatchlist'); try { // 1. 先检查 Redux 内存缓存 const reduxCached = getState().stock.watchlist; if (reduxCached && reduxCached.length > 0) { logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length }); return reduxCached; } // 2. 再检查 localStorage 持久缓存(7天有效期) const localCached = loadWatchlistFromCache(); if (localCached && localCached.length > 0) { return localCached; } // 3. 缓存无效,调用 API const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/watchlist`, { credentials: 'include' }); const data = await response.json(); if (data.success && data.data) { // 返回完整的股票信息,而不仅仅是 stock_code const watchlistData = data.data.map(item => ({ stock_code: item.stock_code, stock_name: item.stock_name, })); // 保存到 localStorage 缓存 saveWatchlistToCache(watchlistData); logger.debug('stockSlice', '自选股列表加载成功', { count: watchlistData.length }); return watchlistData; } return []; } catch (error) { logger.error('stockSlice', 'loadWatchlist', error); return []; } } ); /** * 加载全部股票列表(用于前端模糊搜索) */ export const loadAllStocks = createAsyncThunk( 'stock/loadAllStocks', async (_, { getState }) => { // 检查缓存 const cached = getState().stock.allStocks; if (cached && cached.length > 0) { logger.debug('stockSlice', 'allStocks 缓存命中', { count: cached.length }); return cached; } logger.debug('stockSlice', 'loadAllStocks'); try { const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/stocklist`, { credentials: 'include' }); const data = await response.json(); if (Array.isArray(data)) { logger.debug('stockSlice', '全部股票列表加载成功', { count: data.length }); return data; } return []; } catch (error) { logger.error('stockSlice', 'loadAllStocks', error); return []; } } ); /** * 加载自选股实时行情 * 用于统一行情刷新,两个面板共用 */ export const loadWatchlistQuotes = createAsyncThunk( 'stock/loadWatchlistQuotes', async () => { logger.debug('stockSlice', 'loadWatchlistQuotes'); try { const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/watchlist/realtime`, { credentials: 'include', cache: 'no-store' }); const data = await response.json(); if (data.success && Array.isArray(data.data)) { logger.debug('stockSlice', '自选股行情加载成功', { count: data.data.length }); return data.data; } return []; } catch (error) { logger.error('stockSlice', 'loadWatchlistQuotes', error); return []; } } ); /** * 加载关注事件列表 * 用于统一关注事件数据源,两个面板共用 */ export const loadFollowingEvents = createAsyncThunk( 'stock/loadFollowingEvents', async () => { logger.debug('stockSlice', 'loadFollowingEvents'); try { const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/events/following`, { credentials: 'include', cache: 'no-store' }); const data = await response.json(); if (data.success && Array.isArray(data.data)) { // 合并重复的事件(用最新的数据) const eventMap = new Map(); for (const evt of data.data) { if (evt && evt.id) { eventMap.set(evt.id, evt); } } const merged = Array.from(eventMap.values()); // 按创建时间降序排列 if (merged.length > 0 && merged[0].created_at) { merged.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); } else { merged.sort((a, b) => (b.id || 0) - (a.id || 0)); } logger.debug('stockSlice', '关注事件列表加载成功', { count: merged.length }); return merged; } return []; } catch (error) { logger.error('stockSlice', 'loadFollowingEvents', error); return []; } } ); /** * 加载用户评论列表 */ export const loadEventComments = createAsyncThunk( 'stock/loadEventComments', async () => { logger.debug('stockSlice', 'loadEventComments'); try { const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/events/posts`, { credentials: 'include', cache: 'no-store' }); const data = await response.json(); if (data.success && Array.isArray(data.data)) { logger.debug('stockSlice', '用户评论列表加载成功', { count: data.data.length }); return data.data; } return []; } catch (error) { logger.error('stockSlice', 'loadEventComments', error); return []; } } ); /** * 切换关注事件状态(关注/取消关注) */ export const toggleFollowEvent = createAsyncThunk( 'stock/toggleFollowEvent', async ({ eventId, isFollowing }) => { logger.debug('stockSlice', 'toggleFollowEvent', { eventId, isFollowing }); const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/events/${eventId}/follow`, { method: 'POST', credentials: 'include' }); const data = await response.json(); if (!response.ok || data.success === false) { throw new Error(data.error || '操作失败'); } return { eventId, isFollowing }; } ); /** * 切换自选股状态 */ export const toggleWatchlist = createAsyncThunk( 'stock/toggleWatchlist', async ({ stockCode, stockName, isInWatchlist }) => { logger.debug('stockSlice', 'toggleWatchlist', { stockCode, stockName, isInWatchlist }); const apiBase = getApiBase(); let response; if (isInWatchlist) { // 移除自选股 response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, credentials: 'include' }); } else { // 添加自选股 response = await fetch(`${apiBase}/api/account/watchlist`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ stock_code: stockCode, stock_name: stockName }) }); } const data = await response.json(); if (!data.success) { throw new Error(data.error || '操作失败'); } return { stockCode, stockName, isInWatchlist }; } ); // ==================== Slice ==================== const stockSlice = createSlice({ name: 'stock', initialState: { // 事件相关股票缓存 { [eventId]: stocks[] } eventStocksCache: {}, // 股票行情 { [stockCode]: quote } quotes: {}, // 事件详情缓存 { [eventId]: detail } eventDetailsCache: {}, // 历史事件缓存 { [eventId]: events[] } historicalEventsCache: {}, // 传导链分析缓存 { [eventId]: analysis } chainAnalysisCache: {}, // 超预期得分缓存 { [eventId]: score } expectationScores: {}, // 自选股列表 [{ stock_code, stock_name }] watchlist: [], // 自选股实时行情 [{ stock_code, stock_name, price, change_percent, ... }] watchlistQuotes: [], // 关注事件列表 [{ id, title, event_type, ... }] followingEvents: [], // 用户评论列表 [{ id, content, event_id, ... }] eventComments: [], // 全部股票列表(用于前端模糊搜索)[{ code, name }] allStocks: [], // 加载状态 loading: { stocks: false, quotes: false, eventDetail: false, historicalEvents: false, chainAnalysis: false, watchlist: false, watchlistQuotes: false, followingEvents: false, eventComments: false, allStocks: false }, // 错误信息 error: null }, reducers: { /** * 更新单个股票行情 */ updateQuote: (state, action) => { const { stockCode, quote } = action.payload; state.quotes[stockCode] = quote; }, /** * 批量更新股票行情 */ updateQuotes: (state, action) => { state.quotes = { ...state.quotes, ...action.payload }; }, /** * 清空行情数据 */ clearQuotes: (state) => { state.quotes = {}; }, /** * 清空指定事件的缓存 */ clearEventCache: (state, action) => { const { eventId } = action.payload; delete state.eventStocksCache[eventId]; delete state.eventDetailsCache[eventId]; delete state.historicalEventsCache[eventId]; delete state.chainAnalysisCache[eventId]; delete state.expectationScores[eventId]; }, /** * 乐观更新:添加自选股(同步) */ optimisticAddWatchlist: (state, action) => { const { stockCode, stockName } = action.payload; // 避免重复添加 const exists = state.watchlist.some(item => item.stock_code === stockCode); if (!exists) { state.watchlist.push({ stock_code: stockCode, stock_name: stockName || '' }); } }, /** * 乐观更新:移除自选股(同步) */ optimisticRemoveWatchlist: (state, action) => { const { stockCode } = action.payload; state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); } }, extraReducers: (builder) => { builder // ===== fetchEventStocks ===== .addCase(fetchEventStocks.pending, (state) => { state.loading.stocks = true; state.error = null; }) .addCase(fetchEventStocks.fulfilled, (state, action) => { const { eventId, stocks } = action.payload; state.eventStocksCache[eventId] = stocks; state.loading.stocks = false; }) .addCase(fetchEventStocks.rejected, (state, action) => { state.loading.stocks = false; state.error = action.error.message; }) // ===== fetchStockQuotes ===== .addCase(fetchStockQuotes.pending, (state) => { state.loading.quotes = true; }) .addCase(fetchStockQuotes.fulfilled, (state, action) => { state.quotes = { ...state.quotes, ...action.payload }; state.loading.quotes = false; }) .addCase(fetchStockQuotes.rejected, (state) => { state.loading.quotes = false; }) // ===== fetchEventDetail ===== .addCase(fetchEventDetail.pending, (state) => { state.loading.eventDetail = true; }) .addCase(fetchEventDetail.fulfilled, (state, action) => { const { eventId, detail } = action.payload; state.eventDetailsCache[eventId] = detail; state.loading.eventDetail = false; }) .addCase(fetchEventDetail.rejected, (state) => { state.loading.eventDetail = false; }) // ===== fetchHistoricalEvents ===== .addCase(fetchHistoricalEvents.pending, (state) => { state.loading.historicalEvents = true; }) .addCase(fetchHistoricalEvents.fulfilled, (state, action) => { const { eventId, events } = action.payload; state.historicalEventsCache[eventId] = events; state.loading.historicalEvents = false; }) .addCase(fetchHistoricalEvents.rejected, (state) => { state.loading.historicalEvents = false; }) // ===== fetchChainAnalysis ===== .addCase(fetchChainAnalysis.pending, (state) => { state.loading.chainAnalysis = true; }) .addCase(fetchChainAnalysis.fulfilled, (state, action) => { const { eventId, analysis } = action.payload; state.chainAnalysisCache[eventId] = analysis; state.loading.chainAnalysis = false; }) .addCase(fetchChainAnalysis.rejected, (state) => { state.loading.chainAnalysis = false; }) // ===== fetchExpectationScore ===== .addCase(fetchExpectationScore.fulfilled, (state, action) => { const { eventId, score } = action.payload; state.expectationScores[eventId] = score; }) // ===== loadWatchlist ===== .addCase(loadWatchlist.pending, (state) => { state.loading.watchlist = true; }) .addCase(loadWatchlist.fulfilled, (state, action) => { state.watchlist = action.payload; state.loading.watchlist = false; }) .addCase(loadWatchlist.rejected, (state) => { state.loading.watchlist = false; }) // ===== loadWatchlistQuotes ===== .addCase(loadWatchlistQuotes.pending, (state) => { state.loading.watchlistQuotes = true; }) .addCase(loadWatchlistQuotes.fulfilled, (state, action) => { state.watchlistQuotes = action.payload; state.loading.watchlistQuotes = false; }) .addCase(loadWatchlistQuotes.rejected, (state) => { state.loading.watchlistQuotes = false; }) // ===== loadAllStocks ===== .addCase(loadAllStocks.pending, (state) => { state.loading.allStocks = true; }) .addCase(loadAllStocks.fulfilled, (state, action) => { state.allStocks = action.payload; state.loading.allStocks = false; }) .addCase(loadAllStocks.rejected, (state) => { state.loading.allStocks = false; }) // ===== toggleWatchlist(乐观更新)===== // pending: 立即更新状态 .addCase(toggleWatchlist.pending, (state, action) => { const { stockCode, stockName, isInWatchlist } = action.meta.arg; if (isInWatchlist) { // 移除 state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); } else { // 添加 const exists = state.watchlist.some(item => item.stock_code === stockCode); if (!exists) { state.watchlist.push({ stock_code: stockCode, stock_name: stockName }); } } }) // rejected: 回滚状态 .addCase(toggleWatchlist.rejected, (state, action) => { const { stockCode, stockName, isInWatchlist } = action.meta.arg; // 回滚:与 pending 操作相反 if (isInWatchlist) { // 之前移除了,现在加回来 const exists = state.watchlist.some(item => item.stock_code === stockCode); if (!exists) { state.watchlist.push({ stock_code: stockCode, stock_name: stockName }); } } else { // 之前添加了,现在移除 state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode); } }) // fulfilled: 同步更新 localStorage 缓存 .addCase(toggleWatchlist.fulfilled, (state) => { // 状态已在 pending 时更新,这里同步到 localStorage saveWatchlistToCache(state.watchlist); }) // ===== loadFollowingEvents ===== .addCase(loadFollowingEvents.pending, (state) => { state.loading.followingEvents = true; }) .addCase(loadFollowingEvents.fulfilled, (state, action) => { state.followingEvents = action.payload; state.loading.followingEvents = false; }) .addCase(loadFollowingEvents.rejected, (state) => { state.loading.followingEvents = false; }) // ===== loadEventComments ===== .addCase(loadEventComments.pending, (state) => { state.loading.eventComments = true; }) .addCase(loadEventComments.fulfilled, (state, action) => { state.eventComments = action.payload; state.loading.eventComments = false; }) .addCase(loadEventComments.rejected, (state) => { state.loading.eventComments = false; }) // ===== toggleFollowEvent(乐观更新)===== // pending: 立即更新状态 .addCase(toggleFollowEvent.pending, (state, action) => { const { eventId, isFollowing } = action.meta.arg; if (isFollowing) { // 当前已关注,取消关注 → 移除 state.followingEvents = state.followingEvents.filter(evt => evt.id !== eventId); } // 添加关注的情况需要事件完整数据,不在这里处理 }) // rejected: 回滚状态(仅取消关注需要回滚) .addCase(toggleFollowEvent.rejected, (state, action) => { // 取消关注失败时,需要刷新列表恢复数据 // 由于没有原始事件数据,这里只能触发重新加载 logger.warn('stockSlice', 'toggleFollowEvent rejected, 需要重新加载关注事件列表'); }); } }); export const { updateQuote, updateQuotes, clearQuotes, clearEventCache, optimisticAddWatchlist, optimisticRemoveWatchlist } = stockSlice.actions; export default stockSlice.reducer;