Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 478 additions & 0 deletions .audit/reverted_patches/4d042d8e_corp_brand_resolver_base.patch

Large diffs are not rendered by default.

260 changes: 260 additions & 0 deletions .audit/reverted_patches/bdbd5754_jwt_integration.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
From bdbd575406179282a4a2a435c5b96fb5624e0ce2 Mon Sep 17 00:00:00 2001
From: bat1120 <bat1120@naver.com>
Date: Tue, 5 May 2026 00:49:54 +0900
Subject: [PATCH] =?UTF-8?q?A1:=20corp=5Fbrand=5Fresolver=20JWT=20=EC=9E=90?=
=?UTF-8?q?=EB=8F=99=20=ED=86=B5=ED=95=A9=20(frontend=20biz=5Fnumber=20?=
=?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=97=90?=
=?UTF-8?q?=EC=84=9C=20=EC=B6=94=EC=B6=9C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

PR #188 의 corp_brand_resolver 가 frontend 가 biz_number 명시 안 보내면
검증 skip 되는 회귀 fix. JWT 토큰에서 자동 추출.

추가:
- _resolve_user_biz_number(user) — JWT UserContext → users.biz_number 조회
* master role: user_id → users.id 매칭
* manager role: owner_id → users.id 매칭 (소속 owner)
- _validate_and_resolve_brand(input_data, current_user) 시그니처 확장
- biz_number 우선순위:
1. input_data.biz_number (frontend 명시)
2. JWT 토큰의 user.user_id 또는 owner_id → users.biz_number
3. 없으면 검증 skip (비회원/개인사업자 호환)

7 endpoint 시그니처에 Depends(get_optional_user) 추가:
- /analyze, /analyze/llm, /analyze/llm/async, /analyze/quick
- /predict, /predict/async, /simulate

import:
- from fastapi import Depends 추가
- from src.services.jwt_auth import UserContext, get_optional_user

기존 동작 유지:
- 비로그인 사용자 → optional auth, current_user=None → skip (기존과 동일)
- frontend biz_number 명시 → 토큰 무시 (frontend 우선)
- frontend biz_number 없음 + 로그인 → 토큰에서 자동 추출 (이번 fix)

DB 변경: 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
backend/src/main.py | 98 ++++++++++++++++++++++++++++++++++-----------
1 file changed, 75 insertions(+), 23 deletions(-)

diff --git a/backend/src/main.py b/backend/src/main.py
index e0a06478..15046217 100644
--- a/backend/src/main.py
+++ b/backend/src/main.py
@@ -47,7 +47,7 @@ import redis.asyncio as aioredis
# LangSmith 트레이싱: langchain import 전에 os.environ 주입 필수
# (langchain SDK는 import 시점에 LANGCHAIN_TRACING_V2를 읽으므로 순서가 중요)
from dotenv import load_dotenv
-from fastapi import FastAPI, HTTPException, Request, Response
+from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
@@ -78,6 +78,7 @@ from src.schemas.simulation_input import SimulationInput
from src.services.auth import AuthService
from src.services.biz_mapper import BizMapper
from src.services.corp_brand_resolver import resolve_brand_for_industry
+from src.services.jwt_auth import UserContext, get_optional_user

from models.explainability.shap_analysis import explain_tcn_prediction
from models.explainability.simulation import (
@@ -226,28 +227,56 @@ def _pipeline_key(input_data: Any) -> str:
return f"{input_data.target_district}:{input_data.business_type}:{input_data.brand_name}:{rent}:{area}:{radius}:{pop_w}"


-def _validate_and_resolve_brand(input_data: SimulationInput) -> None:
+def _resolve_user_biz_number(user: UserContext | None) -> str | None:
+ """JWT user → users.biz_number 조회. master 는 본인, manager 는 owner 의 biz_number."""
+ if user is None:
+ return None
+ target_id = user.owner_id if user.role == "manager" else user.user_id
+ if not target_id:
+ return None
+ try:
+ import sqlalchemy as sa
+
+ engine = sa.create_engine(settings.postgres_url)
+ with engine.connect() as conn:
+ row = conn.execute(
+ sa.text("SELECT biz_number FROM users WHERE id = :id"),
+ {"id": target_id},
+ ).first()
+ return row._mapping["biz_number"] if row else None
+ except Exception as ex:
+ logger.warning(f"[brand_resolver] biz_number 조회 실패: {ex}")
+ return None
+
+
+def _validate_and_resolve_brand(
+ input_data: SimulationInput,
+ current_user: UserContext | None = None,
+) -> None:
"""biz_number 입력 시 corp 검증 + 다업종 corp 의 brand auto-resolve.

- 동작 (input_data.biz_number 가 입력됐을 때만):
+ biz_number 우선순위:
+ 1. ``input_data.biz_number`` (frontend 명시 입력)
+ 2. JWT ``current_user`` 토큰에서 자동 추출 (master.user_id 또는 manager.owner_id)
+ 3. 없으면 검증 skip (개인사업자 / 비회원 호환)
+
+ 동작:
1. business_type 이 사용자 corp 의 운영 업종인지 검증.
2. 운영 외 업종 → HTTPException(400) + 운영 가능 업종 list 응답.
3. 운영 내 업종 + corp 의 해당 업종 brand 가 다른 brand 면 brand_name override.
-
- biz_number 미입력 (개인사업자 / 비회원) → 검증 skip, 사용자 brand_name 그대로.
- FTC 미등록 corp → 검증 skip + 경고 로그.
"""
- if not input_data.biz_number:
+ biz_number = input_data.biz_number or _resolve_user_biz_number(current_user)
+ if not biz_number:
return

- result = resolve_brand_for_industry(input_data.biz_number, input_data.business_type)
+ result = resolve_brand_for_industry(biz_number, input_data.business_type)

if result.get("error") == "INDUSTRY_NOT_OPERATED":
raise HTTPException(status_code=400, detail=result)

if result.get("error") in {"USER_NOT_FOUND", "CORP_NOT_IN_FTC", "INVALID_COMPANY_NAME"}:
# 비회원 / FTC 미등록 → 검증 skip, 사용자 brand_name 그대로
- logger.warning(f"[brand_resolver] {result['error']} biz={input_data.biz_number} — fallback to input.brand_name")
+ logger.warning(f"[brand_resolver] {result['error']} biz={biz_number} — fallback to input.brand_name")
return

# 성공: brand_name override (사용자가 다른 brand 입력했어도 corp 정합 brand 로 교체)
@@ -963,14 +992,18 @@ async def get_status(job_id: str):


@app.post("/analyze")
-async def analyze_location(input_data: SimulationInput, response: Response):
+async def analyze_location(
+ input_data: SimulationInput,
+ response: Response,
+ current_user: UserContext | None = Depends(get_optional_user),
+):
"""[DEPRECATED] 풀파이프 상권 분석 — 전환 기간 동안만 유지.

IM3-259로 endpoint를 분리(/predict + /analyze/llm)했으므로 신규 호출은
그쪽으로 옮길 것. 이 endpoint는 기존 프론트/테스트 호환을 위해 유지하다가
충분히 검증되면 제거 예정.
"""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.config.constants import MAPO_DISTRICTS

# IM3-259: deprecation 헤더 — 클라이언트가 /predict + /analyze/llm 으로 옮길 것을 알림
@@ -1021,12 +1054,15 @@ async def analyze_location(input_data: SimulationInput, response: Response):


@app.post("/analyze/llm")
-async def analyze_llm(input_data: SimulationInput):
+async def analyze_llm(
+ input_data: SimulationInput,
+ current_user: UserContext | None = Depends(get_optional_user),
+):
"""AI 분석 전용 endpoint — slow_graph 실행 (~80-140초).

/predict와 독립 병렬 호출 가능. winner는 ranking 단계에서 자체 결정.
"""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.config.constants import MAPO_DISTRICTS
from src.schemas.simulation_output import AnalysisOutput

@@ -1094,9 +1130,12 @@ _SLOW_GRAPH_NODE_TOTAL = 4


@app.post("/analyze/llm/async")
-async def analyze_llm_async(input_data: SimulationInput) -> dict[str, Any]:
+async def analyze_llm_async(
+ input_data: SimulationInput,
+ current_user: UserContext | None = Depends(get_optional_user),
+) -> dict[str, Any]:
"""AI 분석 비동기 시작 — 즉시 job_id 반환. LangGraph 노드별 진행률 추적."""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.config.constants import MAPO_DISTRICTS
from src.schemas.simulation_output import AnalysisOutput
from src.services.job_progress_store import (
@@ -1208,7 +1247,10 @@ async def analyze_llm_job_status(job_id: str) -> dict[str, Any]:


@app.post("/analyze/quick")
-async def analyze_quick(input_data: SimulationInput):
+async def analyze_quick(
+ input_data: SimulationInput,
+ current_user: UserContext | None = Depends(get_optional_user),
+):
"""
LLM 없는 경량 랭킹 엔드포인트 (district_ranking 에이전트만 실행).

@@ -1217,7 +1259,7 @@ async def analyze_quick(input_data: SimulationInput):

응답: { district_rankings, winner_district, top_3_candidates }
"""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.agents.nodes.district_ranking import district_ranking_node
from src.agents.nodes.market_analyst import db_client

@@ -1692,7 +1734,10 @@ def _mock_simulation_response(target_district: str, request_id: str) -> dict:


@app.post("/predict")
-async def predict_districts(input_data: SimulationInput):
+async def predict_districts(
+ input_data: SimulationInput,
+ current_user: UserContext | None = Depends(get_optional_user),
+):
"""
선택 동 1~4개 ML 예측 전용 엔드포인트 (LangGraph 미사용)

@@ -1700,7 +1745,7 @@ async def predict_districts(input_data: SimulationInput):
- target_districts 전체에 대해 TCN/BEP/폐업률/폐업위험도/SHAP 병렬 실행
- 응답: 동별 예측 결과 리스트 (프론트 멀티라인 차트용)
"""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.config.constants import MAPO_DISTRICTS

target_districts = getattr(input_data, "target_districts", None) or [input_data.target_district]
@@ -1762,9 +1807,12 @@ async def predict_districts(input_data: SimulationInput):
# 단계: 동별 _predict_single_district 가 끝날 때마다 progress = done/total.
# ---------------------------------------------------------------------------
@app.post("/predict/async")
-async def predict_districts_async(input_data: SimulationInput) -> dict[str, Any]:
+async def predict_districts_async(
+ input_data: SimulationInput,
+ current_user: UserContext | None = Depends(get_optional_user),
+) -> dict[str, Any]:
"""ML 예측 비동기 시작 — 즉시 job_id 반환. 진행률은 status endpoint 폴링."""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
from src.config.constants import MAPO_DISTRICTS
from src.services.job_progress_store import (
create_job,
@@ -1878,9 +1926,13 @@ async def predict_job_status(job_id: str) -> dict[str, Any]:


@app.post("/simulate", deprecated=True)
-async def run_simulation(input_data: SimulationInput, response: Response):
+async def run_simulation(
+ input_data: SimulationInput,
+ response: Response,
+ current_user: UserContext | None = Depends(get_optional_user),
+):
"""기본 시뮬레이션 엔드포인트"""
- _validate_and_resolve_brand(input_data)
+ _validate_and_resolve_brand(input_data, current_user)
response.headers["Deprecation"] = "true"
response.headers["Link"] = '</predict>; rel="successor-version", </analyze/llm>; rel="successor-version"'

--
2.53.0.windows.2

57 changes: 57 additions & 0 deletions .audit_post_session.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
### POST-SESSION DB 실측 (PR #178~#197 머지 후) ###

=== alembic head ===
91b66e68ec18

=== 테이블 + row counts (top 30) ===
('living_population_grid', 10538127)
('bus_boarding_daily', 3710508)
('seoul_ttareungi_usage_daily', 985653)
('living_population', 961071)
('seoul_adstrd_stor', 849552)
('district_sales_seoul', 475334)
('sgis_population', 224517)
('seoul_subway_passenger_daily', 199340)
('golmok_commercial', 178840)
('jeonse_monthly_rent', 168342)
('sgis_business', 137356)
('seoul_district_stores', 100587)
('seoul_training_dataset', 87938)
('seoul_district_sales', 87938)
('kakao_store_menu', 81037)
('seoul_signgu_stor', 69704)
('seoul_signgu_selng', 43043)
('ftc_brand_franchise', 34708)
('naver_trend_monthly', 33985)
('store_info', 30488)
('sgis_household', 25550)
('golmok_stores', 15800)
('seoul_resident_pop_quarterly', 13508)
('seoul_adstrd_flpop', 11900)
('seoul_golmok_rent', 11900)
('seoul_adstrd_change_ix', 11900)
('mart_brand_territory', 11849)
('langchain_pg_embedding', 10255)
('seoul_population_quarterly', 10176)
('small_store_rent_q', 10020)

=== 100% NULL 컬럼 재측정 (master 메타 backfill 후) ===
district_sales_seoul: 100% NULL = ['raw_json']
dong_mapping: 100% NULL = ['trdar_codes']
ecos_timeseries: 100% NULL = ['item_name2', 'cycle']
invite_codes: 100% NULL = ['expires_at']
kakao_store_hours: 100% NULL = ['mon_hours', 'tue_hours', 'wed_hours', 'thu_hours', 'fri_hours', 'sat_hours', 'sun_hours']
living_population: 100% NULL = ['male_70_74', 'female_70_74']
mart_brand_territory: 100% NULL = ['extraction_confidence']
master_ttareungi_station: 100% NULL = ['dong_code', 'opened_at']
molit_nrg_trade: 100% NULL = ['realty_type']
rent_cost: 100% NULL = ['transaction_date', 'price', 'floor_area', 'floor']
seoul_dong_master: 100% NULL = ['comment']

=== orphan FK ===
✓ orphan 0

=== ORM ↔ DB 정합 ===
ORM: 77, DB: 87
ORM only (zombie): []
DB only (raw SQL): ['alembic_version', 'langchain_pg_collection', 'langchain_pg_embedding', 'living_population_grid', 'mapo_schools', 'password_reset_tokens', 'seoul_district_sales_imputed_v4', 'seoul_district_sales_imputed_v4_detail', 'seoul_resident_pop_quarterly', 'user_usage']
Loading
Loading