Skip to content

Commit 7d1d69b

Browse files
committed
Introduce session tracking via Redis sets and limit user sessions
- Replace single session storage with Redis sets to manage multiple sessions per user. - Enforce a maximum of 5 active sessions per user; remove oldest session when the limit is exceeded. - Add helper function `_build_analysis_response` for consistent API response formatting. - Introduce endpoint to fetch the latest analysis by symbol (`/api/detail/by-symbol/{symbol}`). - Update frontend to use symbol-based lookups for stock and coin analysis, ensuring retrieval of the latest data.
1 parent e03dde7 commit 7d1d69b

File tree

6 files changed

+104
-77
lines changed

6 files changed

+104
-77
lines changed

app/auth/web_router.py

Lines changed: 31 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Web authentication router with HTML pages and session management."""
22
import hashlib
3-
import hmac
43
import json
54
import logging
65
import string
@@ -42,6 +41,7 @@
4241
SESSION_COOKIE_NAME = "session"
4342
SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
4443
USER_CACHE_TTL = 300 # 5 minutes
44+
MAX_SESSIONS_PER_USER = 5
4545
SESSION_HASH_KEY_PREFIX = "user_session"
4646
USER_CACHE_KEY_PREFIX = "user_cache"
4747

@@ -151,31 +151,26 @@ async def get_current_user_from_session(
151151
settings.get_redis_url(),
152152
decode_responses=True,
153153
)
154-
stored_session_hash = await redis_client.get(session_hash_key)
155-
if stored_session_hash:
156-
if not hmac.compare_digest(stored_session_hash, session_hash):
157-
return None
158-
159-
session_hash_verified = True
160-
161-
cached_user = await redis_client.get(user_cache_key)
162-
163-
if cached_user:
164-
user_data = json.loads(cached_user)
165-
user = User(
166-
id=user_data["id"],
167-
username=user_data["username"],
168-
email=user_data["email"],
169-
role=UserRole[user_data["role"]],
170-
is_active=user_data["is_active"],
171-
hashed_password=user_data.get("hashed_password"),
172-
)
173-
if user.is_active:
174-
return user
175-
return None
176-
elif settings.ENVIRONMENT != "production":
177-
redis_error = True
178-
else:
154+
is_member = await redis_client.sismember(session_hash_key, session_hash)
155+
if not is_member:
156+
return None
157+
158+
session_hash_verified = True
159+
160+
cached_user = await redis_client.get(user_cache_key)
161+
162+
if cached_user:
163+
user_data = json.loads(cached_user)
164+
user = User(
165+
id=user_data["id"],
166+
username=user_data["username"],
167+
email=user_data["email"],
168+
role=UserRole[user_data["role"]],
169+
is_active=user_data["is_active"],
170+
hashed_password=user_data.get("hashed_password"),
171+
)
172+
if user.is_active:
173+
return user
179174
return None
180175
except Exception:
181176
redis_error = True
@@ -212,9 +207,8 @@ async def get_current_user_from_session(
212207
await redis_client.set(
213208
user_cache_key, json.dumps(user_data), ex=USER_CACHE_TTL
214209
)
215-
await redis_client.set(
216-
session_hash_key, session_hash, ex=SESSION_TTL
217-
)
210+
await redis_client.sadd(session_hash_key, session_hash)
211+
await redis_client.expire(session_hash_key, SESSION_TTL)
218212
except Exception:
219213
logger.warning(
220214
"Failed to refresh session cache for user_id=%s",
@@ -376,6 +370,7 @@ async def login(
376370
settings.get_redis_url(),
377371
decode_responses=True,
378372
)
373+
session_hash_key = _session_hash_key(user.id)
379374
user_data = {
380375
"id": user.id,
381376
"username": user.username,
@@ -384,9 +379,11 @@ async def login(
384379
"is_active": user.is_active,
385380
"hashed_password": user.hashed_password,
386381
}
387-
await redis_client.set(
388-
_session_hash_key(user.id), session_hash, ex=SESSION_TTL
389-
)
382+
session_count = await redis_client.scard(session_hash_key)
383+
if session_count >= MAX_SESSIONS_PER_USER:
384+
await redis_client.spop(session_hash_key)
385+
await redis_client.sadd(session_hash_key, session_hash)
386+
await redis_client.expire(session_hash_key, SESSION_TTL)
390387
await redis_client.set(
391388
_user_cache_key(user.id), json.dumps(user_data), ex=USER_CACHE_TTL
392389
)
@@ -572,15 +569,8 @@ async def logout(request: Request):
572569
settings.get_redis_url(),
573570
decode_responses=True,
574571
)
575-
# Add to blacklist to invalidate all sessions for this user (optional but safer)
576-
blacklist = get_session_blacklist()
577-
await blacklist.blacklist_user(user_id)
578-
579-
# Delete session key from Redis
580-
await redis_client.delete(
581-
_session_hash_key(user_id),
582-
_user_cache_key(user_id),
583-
)
572+
session_hash = _hash_session_token(session_token)
573+
await redis_client.srem(_session_hash_key(user_id), session_hash)
584574
except Exception:
585575
logger.warning(
586576
"Failed to invalidate session cache during logout for user_id=%s",

app/routers/analysis_json.py

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -217,27 +217,10 @@ async def get_analysis_results(
217217
raise
218218

219219

220-
@router.get("/api/detail/{result_id}")
221-
async def get_analysis_detail(
222-
result_id: int,
223-
db: AsyncSession = Depends(get_db)
224-
):
225-
"""특정 분석 결과의 상세 정보를 조회하는 API"""
226-
227-
query = select(StockAnalysisResult, StockInfo).join(
228-
StockInfo, StockAnalysisResult.stock_info_id == StockInfo.id
229-
).where(StockAnalysisResult.id == result_id)
230-
231-
result = await db.execute(query)
232-
row = result.first()
233-
234-
if not row:
235-
return {"error": "분석 결과를 찾을 수 없습니다."}
236-
237-
analysis_result, stock_info = row
238-
220+
def _build_analysis_response(analysis_result: StockAnalysisResult, stock_info: StockInfo) -> dict:
221+
"""분석 결과를 응답 형식으로 변환하는 헬퍼 함수"""
239222
reasons = _normalize_reasons(analysis_result.reasons)
240-
223+
241224
return {
242225
"id": analysis_result.id,
243226
"symbol": stock_info.symbol,
@@ -269,6 +252,52 @@ async def get_analysis_detail(
269252
}
270253

271254

255+
@router.get("/api/detail/{result_id}")
256+
async def get_analysis_detail(
257+
result_id: int,
258+
db: AsyncSession = Depends(get_db)
259+
):
260+
"""특정 분석 결과의 상세 정보를 조회하는 API"""
261+
262+
query = select(StockAnalysisResult, StockInfo).join(
263+
StockInfo, StockAnalysisResult.stock_info_id == StockInfo.id
264+
).where(StockAnalysisResult.id == result_id)
265+
266+
result = await db.execute(query)
267+
row = result.first()
268+
269+
if not row:
270+
return {"error": "분석 결과를 찾을 수 없습니다."}
271+
272+
analysis_result, stock_info = row
273+
return _build_analysis_response(analysis_result, stock_info)
274+
275+
276+
@router.get("/api/detail/by-symbol/{symbol}")
277+
async def get_latest_analysis_by_symbol(
278+
symbol: str,
279+
db: AsyncSession = Depends(get_db)
280+
):
281+
"""특정 종목의 최신 분석 결과를 조회하는 API"""
282+
283+
query = select(StockAnalysisResult, StockInfo).join(
284+
StockInfo, StockAnalysisResult.stock_info_id == StockInfo.id
285+
).where(
286+
StockInfo.symbol == symbol
287+
).order_by(
288+
StockAnalysisResult.created_at.desc()
289+
).limit(1)
290+
291+
result = await db.execute(query)
292+
row = result.first()
293+
294+
if not row:
295+
return {"error": "분석 결과를 찾을 수 없습니다."}
296+
297+
analysis_result, stock_info = row
298+
return _build_analysis_response(analysis_result, stock_info)
299+
300+
272301
@router.get("/api/filters")
273302
async def get_filter_options(db: AsyncSession = Depends(get_db)):
274303
"""필터 옵션을 조회하는 API"""

app/templates/kis_domestic_trading_dashboard.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,12 +1045,13 @@ <h6 class="card-title mb-3">
10451045
// Individual stock action handlers
10461046
document.querySelectorAll('.stock-analyze-btn').forEach(btn => {
10471047
btn.addEventListener('click', () => {
1048-
const analysisId = btn.dataset.id;
1048+
const hasAnalysis = btn.dataset.id !== ''; // 기존 분석 존재 여부
10491049
const symbol = btn.dataset.code;
10501050
const name = btn.dataset.name;
10511051

1052-
if (analysisId) {
1053-
openStockAnalysisDetail(analysisId, symbol, name);
1052+
if (hasAnalysis) {
1053+
// 항상 최신 분석을 symbol 기반으로 조회
1054+
openStockAnalysisDetail(symbol, name);
10541055
} else {
10551056
if (confirm(`${name} (${symbol})에 대한 개별 AI 분석을 실행하시겠습니까?`)) {
10561057
triggerIndividualStockTask('analyze-stock', symbol, name);
@@ -1142,7 +1143,7 @@ <h6 class="card-title mb-3">
11421143
.replace(/'/g, "&#039;");
11431144
}
11441145

1145-
async function openStockAnalysisDetail(analysisId, symbol, name) {
1146+
async function openStockAnalysisDetail(symbol, name) {
11461147
const modalElement = document.getElementById('stockAnalysisModal');
11471148
const modalTitle = modalElement.querySelector('.modal-title');
11481149
const modalBody = modalElement.querySelector('.modal-body');
@@ -1168,7 +1169,8 @@ <h6 class="card-title mb-3">
11681169
modal.show();
11691170

11701171
try {
1171-
const response = await fetch(`/analysis-json/api/detail/${analysisId}`);
1172+
// symbol 기반으로 최신 분석 결과를 조회
1173+
const response = await fetch(`/analysis-json/api/detail/by-symbol/${symbol}`);
11721174
if (!response.ok) {
11731175
throw new Error('Network response was not ok');
11741176
}

app/templates/kis_overseas_trading_dashboard.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,12 +1034,13 @@ <h6 class="card-title mb-3">
10341034

10351035
document.querySelectorAll('.stock-analyze-btn').forEach(btn => {
10361036
btn.addEventListener('click', () => {
1037-
const analysisId = btn.dataset.id;
1037+
const hasAnalysis = btn.dataset.id !== ''; // 기존 분석 존재 여부
10381038
const symbol = btn.dataset.code;
10391039
const name = btn.dataset.name;
10401040

1041-
if (analysisId) {
1042-
openStockAnalysisDetail(analysisId, symbol, name);
1041+
if (hasAnalysis) {
1042+
// 항상 최신 분석을 symbol 기반으로 조회
1043+
openStockAnalysisDetail(symbol, name);
10431044
} else {
10441045
if (confirm(`${name} (${symbol})에 대한 개별 AI 분석을 실행하시겠습니까?`)) {
10451046
triggerIndividualStockTask('analyze-stock', symbol, name);
@@ -1132,7 +1133,7 @@ <h6 class="card-title mb-3">
11321133
.replace(/'/g, "&#039;");
11331134
}
11341135

1135-
async function openStockAnalysisDetail(analysisId, symbol, name) {
1136+
async function openStockAnalysisDetail(symbol, name) {
11361137
const modalElement = document.getElementById('stockAnalysisModal');
11371138
const modalTitle = modalElement.querySelector('.modal-title');
11381139
const modalBody = modalElement.querySelector('.modal-body');
@@ -1158,7 +1159,8 @@ <h6 class="card-title mb-3">
11581159
modal.show();
11591160

11601161
try {
1161-
const response = await fetch(`/analysis-json/api/detail/${analysisId}`);
1162+
// symbol 기반으로 최신 분석 결과를 조회
1163+
const response = await fetch(`/analysis-json/api/detail/by-symbol/${symbol}`);
11621164
if (!response.ok) {
11631165
throw new Error('Network response was not ok');
11641166
}

app/templates/stock_latest_dashboard.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ <h6>분석 이력 (총 ${data.total_count}건)</h6>
718718
}
719719

720720
// 최신 분석 상세 정보 표시 (메인 테이블에서 호출)
721+
// symbol 기반으로 항상 최신 분석을 조회
721722
async function showLatestAnalysisDetail(analysisId, symbol, name, stockInfoId) {
722723
const modal = new bootstrap.Modal(document.getElementById('analysisDetailModal'));
723724
const modalTitle = document.getElementById('analysisDetailModalLabel');
@@ -729,7 +730,8 @@ <h6>분석 이력 (총 ${data.total_count}건)</h6>
729730
modal.show();
730731

731732
try {
732-
const response = await fetch(`/analysis-json/api/detail/${analysisId}`);
733+
// symbol 기반으로 최신 분석 결과를 조회 (페이지 로드 시점의 analysisId 대신)
734+
const response = await fetch(`/analysis-json/api/detail/by-symbol/${encodeURIComponent(symbol)}`);
733735
if (!response.ok) throw new Error('Network response was not ok');
734736

735737
const data = await response.json();

app/templates/upbit_trading_dashboard.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,15 +1347,16 @@ <h6 class="border-bottom pb-2 mb-3">
13471347
function attachCoinActionHandlers() {
13481348
document.querySelectorAll('.coin-analysis-btn').forEach(button => {
13491349
button.addEventListener('click', () => {
1350-
const analysisId = Number(button.dataset.analysisId);
1350+
const hasAnalysis = button.dataset.analysisId && Number(button.dataset.analysisId) > 0;
13511351
const symbol = button.dataset.symbol;
13521352
const name = button.dataset.name;
13531353

1354-
if (!analysisId) {
1354+
if (!hasAnalysis) {
13551355
return;
13561356
}
13571357

1358-
openCoinAnalysisDetail(analysisId, symbol, name);
1358+
// 항상 최신 분석을 symbol 기반으로 조회
1359+
openCoinAnalysisDetail(symbol, name);
13591360
});
13601361
});
13611362

@@ -1778,7 +1779,7 @@ <h6 class="border-bottom pb-2 mb-3">
17781779
return `${minValue.toLocaleString()} ~ ${maxValue.toLocaleString()}`;
17791780
}
17801781

1781-
async function openCoinAnalysisDetail(analysisId, symbol, name) {
1782+
async function openCoinAnalysisDetail(symbol, name) {
17821783
const modalElement = document.getElementById('coinAnalysisModal');
17831784
const modalTitle = modalElement.querySelector('.modal-title');
17841785
const modalBody = modalElement.querySelector('.modal-body');
@@ -1802,7 +1803,8 @@ <h6 class="border-bottom pb-2 mb-3">
18021803
modal.show();
18031804

18041805
try {
1805-
const response = await fetch(`/analysis-json/api/detail/${analysisId}`);
1806+
// symbol 기반으로 최신 분석 결과를 조회
1807+
const response = await fetch(`/analysis-json/api/detail/by-symbol/${encodeURIComponent(symbol)}`);
18061808
if (!response.ok) {
18071809
throw new Error('Network response was not ok');
18081810
}

0 commit comments

Comments
 (0)