Files
vf_react/src/store/slices/stockSlice.js
zdl a395d49158 fix: 会员过期时跳过事件相关 API 请求
- fetchEventStocks: 新增 skipIfNoAccess 参数
  - fetchHistoricalEvents: 新增 skipIfNoAccess 参数
  - fetchChainAnalysis: 新增 skipIfNoAccess 参数
  - 通过检查 subscription 状态判断是否跳过请求
2025-12-24 18:34:41 +08:00

813 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;