Merge pull request 'feat: 협상 채팅(chat) API + 프론트 연동 (agent 위임 · KST 표시)' (#3) from feature/backend into main

Reviewed-on: Negosium/o2o-negosium#3
This commit is contained in:
minheon 2026-06-18 05:28:42 +00:00
commit 696fb24655
155 changed files with 4763 additions and 652 deletions

72
AGENT_INTEGRATION.md Normal file
View File

@ -0,0 +1,72 @@
# Chat 연동 규약 (backend ↔ agent)
> 대상: **agent(9500) 담당자**. backend(9300)가 채팅 한 턴을 agent `POST /v1/chat` 으로 위임한다.
> backend/frontend 개발은 이 규약을 가정하고 완료했고, 현재는 `AgentConfig.use_mock=true` 로 내장 mock 을 쓴다.
> agent 가 준비되면 **아래 항목을 맞춘 뒤** backend `config.local.toml` 의 `[AgentConfig] use_mock=false` 로 전환하면 된다.
## 1. 호출 흐름
```
프론트(5173) → backend(9300) /v1/negotiation/sessions/{id}/chat/send → agent(9500) POST /v1/chat
```
- 인증·소유권·견적마감·가격범위 검증, 말풍선 영속화(negotiation.chats), 종료 시 세션 입찰확정은 **backend 책임**.
- 협상 로직(스텝 전이·카드선택·학습)은 **agent 책임**. backend 는 agent 응답을 그대로 말풍선으로 저장/전달한다.
## 2. backend → agent 요청 (`POST /v1/chat`)
```json
{
"session_id": "<negotiation.sessions.session_id>",
"rq_type": "재협상 | 재견적",
"user_input": "<버튼 텍스트 또는 가격문자열, 첫 턴(오프닝)은 null>",
"target_price": 100000,
"anchor_price": 99000
}
```
헤더: `X-Tenant-ID: <견적(갑) 회사 company_id>`
## 3. agent → backend 응답 (`Res_Chat`) ↔ 프론트 ChatMessage 매핑
| agent 필드 | backend/프론트 |
|---|---|
| `session_id` | 세션 키 |
| `step` | `step` |
| `client_step` | `display_step` |
| `script` | `script` (말풍선 텍스트) |
| `input_mode` | `next_input_mode` (confirm·yes_no·percent·price·delivery_type) |
| `input_options` | `next_input_type` (버튼 라벨 배열) |
| `chat_end` | `chat_end` |
| `outcome` | "success"=협상완료(DONE)+입찰가 확정 / 그 외=협상거부(REJECTED) |
| `card_id` | (저장만, 표시 범위 외) |
## 4. agent 쪽에서 맞춰줘야 하는 항목 ⚠️
1. **session_id honoring** — 첫 턴에 backend 가 보낸 `session_id`(우리 `negotiation.sessions.session_id`)를
**새 uuid 발급 없이 그대로 세션 키로 사용**해야 한다.
- 현재 `agent/services/chat_service.py` 는 새 세션 생성 시 `session_id=str(uuid.uuid4())` 로 무시한다 → `req.session_id` 우선 사용하도록 수정 필요.
- 이미 `learning.experience_logs.session_id` 가 `negotiation.sessions` 를 가리키도록 설계돼 있어 agent 입장에서도 올바른 방향.
2. **tenant 헤더** — backend 가 `X-Tenant-ID = company_id` 로 보낸다. agent 의 TenantMiddleware 가 이 키로 엔진 해석.
3. **신규 세션 컨텍스트** — `target_price`/`anchor_price`/`rq_type` 를 backend 가 견적 데이터로 채워 보낸다(기본값 의존 X).
4. **tenant_id 정밀 해석(backend 측 TODO와 짝)** — 현재 backend 는 `X-Tenant-ID` 를 빈 값으로 보낸다.
정확히는 **견적 작성자(갑) 회사 company_id** 여야 하며, `quotation.user_id → company.users.company_id` 조회로 채울 예정.
agent 가 기대하는 tenant 키 형식(company_id uuid 문자열 / `_base`)을 확정해주면 backend 가 맞춘다.
5. **(범위 외) indicator / summary / reject** — 이번 범위 미포함. agent 응답에 협상지표·최종요약·거부폼이 생기면
backend ChatMessage 의 예약 필드(`indicator_value`/`bot_chat_type`/summary)로 확장 협의.
## 5. mock → 실제 전환 체크리스트
- [x] backend `config.local.toml` → `[AgentConfig] use_mock=false` (전환 완료 — agent 미기동 시 1402 로 graceful degrade 확인)
- [x] backend `httpx` 의존 설치(`requirements.txt` 반영됨)
- [ ] 위 4-1 ~ 4-3 반영 (agent 측)
- [ ] tenant_id 해석(4-4) 합의 후 backend `chat_service._agent_context` 의 `tenant_id` 채우기
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E
> 로컬에서 agent 없이 mock 으로 개발하려면 환경변수로 덮는다: `AGENT_USE_MOCK=true`
## 6. 참고 (backend 구현 위치)
- agent 어댑터: `backend/services/agent_client.py` (IAgentClient / Http / Mock)
- 오케스트레이션: `backend/services/chat_service.py`
- 계약(프로토콜): `backend/router/v1/negotiation/chat_protocol.py`
- 엔드포인트: `backend/router/v1/negotiation/chat.py`

View File

@ -34,17 +34,27 @@ class DBSessionManager(Singleton):
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
self.__engines = []
# 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다.
# USER/PARTNER/NEGOTIATION/QUOTATION 은 물리적으로 같은 negosium_db 라 main_db_config 를 재사용한다(도메인별 논리 구분용).
self.__db_type_map = {
DBType.MAIN.value: main_db_config,
DBType.USER.value: main_db_config,
DBType.PARTNER.value: main_db_config,
DBType.NEGOTIATION.value: main_db_config,
DBType.QUOTATION.value: main_db_config,
}
# Write 엔진 맵
self.__write_session = {
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_WRITE.value),
DBType.USER.value: self.create_engine(DBType.USER.value, DBWRType.DB_WRITE.value),
DBType.PARTNER.value: self.create_engine(DBType.PARTNER.value, DBWRType.DB_WRITE.value),
DBType.NEGOTIATION.value: self.create_engine(DBType.NEGOTIATION.value, DBWRType.DB_WRITE.value),
DBType.QUOTATION.value: self.create_engine(DBType.QUOTATION.value, DBWRType.DB_WRITE.value),
}
# Read 엔진 맵
self.__read_session = {
DBType.MAIN.value: self.create_engine(DBType.MAIN.value, DBWRType.DB_READ.value),
DBType.USER.value: self.create_engine(DBType.USER.value, DBWRType.DB_READ.value),
DBType.PARTNER.value: self.create_engine(DBType.PARTNER.value, DBWRType.DB_READ.value),
DBType.NEGOTIATION.value: self.create_engine(DBType.NEGOTIATION.value, DBWRType.DB_READ.value),
DBType.QUOTATION.value: self.create_engine(DBType.QUOTATION.value, DBWRType.DB_READ.value),
}
def create_engine(self, db_type: int, db_wr_type: int):

View File

@ -1,5 +1,6 @@
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, SmallInteger, BigInteger, Numeric
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text
from common.enums import DBType
@ -8,19 +9,191 @@ from common.enums import DBType
MAIN_BASE = declarative_base()
class tbl_account(MAIN_BASE):
# 모델이 자신이 속한 논리 DB 를 알려준다 (람다 실행 시 DBType 으로 세션 선택).
class supplier_users(MAIN_BASE):
# 이 프로젝트의 기본 유저. 실제 테이블은 negosium_db 의 supplier 스키마(supplier_users).
@staticmethod
def DBType():
return DBType.MAIN.value
return DBType.USER.value
__tablename__ = "tbl_account"
__tablename__ = "supplier_users"
__table_args__ = {"schema": "supplier"}
uid = Column(Integer, primary_key=True, autoincrement=True)
id = Column(String(45), nullable=False, unique=True) # 로그인 ID. 중복 가입 방지 위해 unique.
pw = Column(String(255), nullable=False, default="") # bcrypt 해시 저장
nickname = Column(String(45), nullable=False, default="")
is_blocked = Column(Boolean, nullable=False, default=False)
# PostgreSQL UTC now: now() 는 timestamptz 이므로 utc 로 변환해 timestamp 로 저장.
last_login_at = Column(DateTime, nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"))
create_at = Column(DateTime, server_default=text("(now() AT TIME ZONE 'utc')"))
# gen_random_uuid() 는 pgcrypto 확장 기준. 코드값(status/role/type)은 SMALLINT 정수 코드(앱 enum 매핑).
su_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 유저 식별자(PK)
supplier_id = Column(UUID(as_uuid=True), nullable=False) # 소속 공급사(partner.suppliers.supplier_id)
id = Column(String(20), nullable=False) # 로그인 ID
password = Column(String(255), nullable=False) # 해시된 비밀번호이어야 함
name = Column(String(50), nullable=True) # 이름
email = Column(String(255), nullable=True) # 이메일
contact_number = Column(String(20), nullable=True) # 연락처
last_accessed_at = Column(DateTime(timezone=True), nullable=False) # 마지막 접속 시각
status = Column(SmallInteger, nullable=False, server_default=text("1")) # 상태: 1=active, 2=inactive
role = Column(SmallInteger, nullable=False, server_default=text("1")) # 권한: 1=user, 2=manager
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class suppliers(MAIN_BASE):
# partner.suppliers (공급사 회사). 공급사명(name) 조회용. partner 도메인이라 DBType 은 PARTNER.
@staticmethod
def DBType():
return DBType.PARTNER.value
__tablename__ = "suppliers"
__table_args__ = {"schema": "partner"}
supplier_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 공급사 식별자(PK)
company_id = Column(UUID(as_uuid=True), nullable=False) # 소속 회사(company.companies.company_id)
user_id = Column(UUID(as_uuid=True), nullable=False) # 등록 유저(company.users.user_id)
name = Column(String(100), nullable=False) # 공급사명
code = Column(String(20), nullable=True) # 공급사 코드
manager_name = Column(String(50), nullable=True) # 담당자명
manager_email = Column(String(255), nullable=True) # 담당자 이메일
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
priority = Column(String(10), nullable=True) # 우선순위 (고객사별 문자열 값 가능)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class items(MAIN_BASE):
# partner.items (상품).
@staticmethod
def DBType():
return DBType.PARTNER.value
__tablename__ = "items"
__table_args__ = {"schema": "partner"}
item_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 상품 식별자(PK)
company_id = Column(UUID(as_uuid=True), nullable=False) # 소속 회사(company.companies.company_id)
user_id = Column(UUID(as_uuid=True), nullable=False) # 등록 유저(company.users.user_id)
name = Column(String(100), nullable=False) # 상품명
code = Column(String(30), nullable=True) # 상품 코드
price = Column(BigInteger, nullable=True) # 가격(원)
category = Column(String(255), nullable=True) # 카테고리
image_url = Column(String(255), nullable=True) # 이미지 URL
model_name = Column(String(100), nullable=True) # 모델명
spec = Column(String(255), nullable=True) # 규격
moq = Column(String(50), nullable=True) # 최소 주문 수량
lead_time = Column(SmallInteger, nullable=True) # 배송 리드타임
manufacturer = Column(String(50), nullable=True) # 제조사
made_in = Column(String(100), nullable=True) # 원산지
quantity_unit = Column(SmallInteger, nullable=True) # 취급 단위 (코드, 앱 enum 매핑)
delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 (코드, 앱 enum 매핑)
vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부
delivery_fee_yn = Column(Boolean, nullable=True) # 배송비 포함 여부
internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class sessions(MAIN_BASE):
# negotiation.sessions (협상 세션).
@staticmethod
def DBType():
return DBType.NEGOTIATION.value
__tablename__ = "sessions"
__table_args__ = {"schema": "negotiation"}
session_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 협상 세션 식별자(PK)
quotation_id = Column(UUID(as_uuid=True), nullable=False) # 소속 견적(quotation.quotations.qt_id)
item_id = Column(UUID(as_uuid=True), nullable=False) # 대상 상품(partner.items.item_id)
supplier_id = Column(UUID(as_uuid=True), nullable=False) # 대상 공급사(partner.suppliers.supplier_id)
qt_number = Column(String(30), nullable=False) # 견적번호(스냅샷)
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원)
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
end_time = Column(DateTime(timezone=True), nullable=False) # 세션 종료(마감) 시각
reject_reason = Column(String(255), nullable=True) # 거절 사유
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class quotations(MAIN_BASE):
# quotation.quotations (견적).
@staticmethod
def DBType():
return DBType.QUOTATION.value
__tablename__ = "quotations"
__table_args__ = {"schema": "quotation"}
qt_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 견적 식별자(PK)
user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
qt_setting_id = Column(UUID(as_uuid=True), nullable=False) # 견적 설정(quotation.quotation_settings.qt_setting_id)
version_id = Column(UUID(as_uuid=True), nullable=False) # 버전(card.versions.version_id)
name = Column(String(50), nullable=False) # 견적명
number = Column(String(30), nullable=False) # 견적번호
type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
round = Column(Integer, nullable=False, server_default=text("1")) # 재견적 회차
status = Column(SmallInteger, nullable=False) # 진행 상태 (QuotationStatus 코드)
start_time = Column(DateTime(timezone=True), nullable=False) # 견적 시작 시각
end_time = Column(DateTime(timezone=True), nullable=False) # 견적 종료(마감) 시각
manager_name = Column(String(50), nullable=True) # 담당자명
manager_email = Column(String(255), nullable=True) # 담당자 이메일
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
memo = Column(String(100), nullable=True) # 메모
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
preferred_sp_name = Column(String(20), nullable=True) # 선호 공급사명(스냅샷)
equal_bid_yn = Column(Boolean, nullable=True) # 동일가 입찰 발생 여부
equal_bid_data = Column(JSONB, nullable=True) # 동일가 입찰 상세(JSON)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod
def DBType():
return DBType.NEGOTIATION.value
__tablename__ = "chats"
__table_args__ = {"schema": "negotiation"}
chat_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 채팅 식별자(PK)
session_id = Column(UUID(as_uuid=True), nullable=False) # 소속 세션(negotiation.sessions.session_id)
card_id = Column(UUID(as_uuid=True), nullable=True) # 사용된 카드(card.nego_cards/wild_cards)
seq = Column(Integer, nullable=False, server_default=text("1")) # 세션 내 메시지 순번
sender = Column(SmallInteger, nullable=False) # 발신자 (ChatSender: 1=BOT, 2=USER)
target_price = Column(BigInteger, nullable=False) # 제시 목표가(원)
card_used_yn = Column(Boolean, nullable=True) # 카드 사용 여부
indicator_value = Column(Numeric(8, 6), nullable=True) # 협상 지표값
card_type = Column(SmallInteger, nullable=True) # 카드 유형: 1=nego_card, 2=wild_card
meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class supplier_user_tokens(MAIN_BASE):
# 유저 인증 토큰. supplier_users 1 : N tokens.
@staticmethod
def DBType():
return DBType.USER.value
__tablename__ = "supplier_user_tokens"
__table_args__ = {"schema": "supplier"}
sut_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 토큰 식별자(PK)
su_id = Column(UUID(as_uuid=True), nullable=False) # 소유 유저(supplier.supplier_users.su_id)
type = Column(SmallInteger, nullable=False) # 토큰 종류 (코드, 앱 enum 매핑)
token = Column(JSONB, nullable=False) # 토큰 본문(JSON)
issued_at = Column(DateTime(timezone=True), nullable=False) # 발급 시각
expired_at = Column(DateTime(timezone=True), nullable=False) # 만료 시각
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부

View File

@ -35,6 +35,20 @@ class ErrorType(Enum):
ACCOUNT_INVALID_INFO = 1200
ACCOUNT_ALREADY_EXIST = auto()
ACCOUNT_BLOCKED_USER = auto()
TOKEN_REVOKED = auto() # 제시된 토큰이 저장된 토큰과 불일치(로그아웃/타기기 로그인으로 교체됨)
# 협상(negotiation) 관련 에러 — 프론트 toast 용 코드
NEGO_FORBIDDEN = 1300 # 공급사 불일치(권한 없음)
NEGO_NOT_PARTICIPABLE = auto() # 1301 세션 상태가 미참여/협상거부라 참여 불가
NEGO_QUOTATION_CLOSED = auto() # 1302 견적 마감 상태
NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과
NEGO_NOT_FOUND = auto() # 1304 세션/견적 없음
# 채팅(chat) 관련 에러
CHAT_NOT_IN_PROGRESS = 1400 # 협상중 상태가 아니라 대화 불가(미참여/완료/거부)
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
@ -49,9 +63,13 @@ EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_I
class DBType(Enum):
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
물리적으로 같은 negosium_db 라도 도메인별 논리 구분으로 나눠 둘 수 있다(커넥션 config 는 재사용).
"""
MAIN = 1
USER = 1 # 기본 유저 (supplier_users 테이블)
PARTNER = 2 # partner 도메인 (partner.suppliers, partner.items 등)
NEGOTIATION = 3 # negotiation 도메인 (negotiation.sessions 등)
QUOTATION = 4 # quotation 도메인 (quotation.quotations 등)
class DBWRType(Enum):
@ -59,3 +77,66 @@ class DBWRType(Enum):
DB_READ = 1
DB_WRITE = 2
# ============================================================
# 도메인 코드값. 스키마는 SMALLINT 정수 코드(1부터)로 두고, 의미 매핑은 여기 enum 으로 한다.
# (postgres-init/01-schema.sql: "코드값(status/role/type 등)은 SMALLINT 정수 코드로 둔다")
# ============================================================
class AccountStatus(Enum):
"""계정 상태 코드. company.users / supplier.supplier_users 의 status 컬럼."""
ACTIVE = 1 # 활성
INACTIVE = 2 # 비활성
class UserRole(Enum):
"""유저 권한 코드. company.users / supplier.supplier_users 의 role 컬럼."""
USER = 1 # 일반 유저
MANAGER = 2 # 매니저
class TokenType(Enum):
"""토큰 종류 코드. supplier.supplier_user_tokens 의 type 컬럼."""
ACCESS = 1
REFRESH = 2
class QtType(Enum):
"""견적/세션 유형 코드. quotation.quotations.type / negotiation.sessions.qt_type."""
RENEGO = 1 # 재협상(1:1)
REQUOTE = 2 # 재견적(1:N)
class SessionStatus(Enum):
"""협상 세션 진행 상태 코드. negotiation.sessions.status.
⚠️ 세션을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
"""
CREATED = 1 # 협상생성
IN_PROGRESS = 2 # 협상중
DONE = 3 # 협상완료
NOT_PARTICIPATED = 4 # 미참여
REJECTED = 5 # 협상거부
class QuotationStatus(Enum):
"""견적 진행 상태 코드. quotation.quotations.status.
⚠️ 견적을 생성/갱신하는 쪽(바이어/agent)과 코드값이 일치해야 한다.
"""
CREATED = 1 # 견적생성
IN_PROGRESS = 2 # 견적진행중
CLOSED = 3 # 견적마감
class ChatSender(Enum):
"""채팅 발신자 코드. negotiation.chats.sender.
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
"""
BOT = 1 # 갑(바이어/agent) — bot 메시지
USER = 2 # 공급사(을) — user 입력

View File

@ -46,11 +46,14 @@ class Res_WebPacketProtocol(WebPacketProtocol):
class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보."""
"""JWT subject 로 인코딩되는 유저 식별 정보. su_id/supplier_id 는 uuid 문자열로 인코딩한다."""
uid: int
su_id: str
id: str
nickname: str
name: str
supplier_id: str
supplier_name: str
role: int
def __init__(self, *args, **kwargs) -> None:
super().__init__()

View File

@ -7,6 +7,8 @@ port = 9300
process_count = 1
is_ssl = false
is_test = true
# CORS 허용 오리진(프론트). 비우면 [] (CORS 미적용). 예: 로컬 Vite = http://localhost:5173
cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
[LogConfig]
print_console = true

View File

@ -7,6 +7,8 @@ class WebServerConfig(ConfigModel):
process_count: int = 1
is_ssl: bool = False
is_test: bool = False
# CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"]
cors_origins: list[str] = []
class LogConfig(ConfigModel):
@ -41,3 +43,10 @@ class JwtToken(ConfigModel):
refresh_key: str = ""
access_expire_min: int = 30
refresh_expire_day: int = 7
# 협상 에이전트(agent, 포트 9500) 접속 설정. backend 가 /chat 한 턴을 agent 로 위임할 때 사용.
class AgentConfig(ConfigModel):
base_url: str = "http://127.0.0.1:9500" # agent 서비스 베이스 URL
timeout_sec: float = 10.0 # 호출 타임아웃(초)
use_mock: bool = True # True 면 agent 미연동 — 내장 mock 응답 사용(agent 개발 중 통합 테스트용)

View File

@ -1,7 +1,7 @@
import os
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, AgentConfig
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
@ -19,6 +19,14 @@ web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken)
agent_config: AgentConfig = configs.get(AgentConfig)
# agent 접속 env override (도커/배포에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
if os.environ.get("AGENT_BASE_URL"):
agent_config.base_url = os.environ["AGENT_BASE_URL"]
if os.environ.get("AGENT_USE_MOCK"):
agent_config.use_mock = os.environ["AGENT_USE_MOCK"].lower() in ("1", "true", "yes")
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -6,7 +6,6 @@ os.environ.setdefault("APP_ENV", "local")
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE
@ -20,15 +19,13 @@ def _write_url(cfg) -> str:
@pytest_asyncio.fixture
async def db_engine():
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
"""테스트용 스키마를 보장한다. 격리는 각 테스트가 전용 행만 시드/정리하는 방식으로 한다.
앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB(config.test.toml)에 접속하므로,
여기서 만든 스키마를 그대로 공유한다.
앱(DB_SESSION_MNG)은 자체 엔진으로 같은 DB 에 접속하므로 여기서 만든 스키마를 그대로 공유한다.
"""
engine = create_async_engine(_write_url(main_db_config))
async with engine.begin() as conn:
await conn.run_sync(MAIN_BASE.metadata.create_all) # 이미 있으면 skip
await conn.execute(text("TRUNCATE TABLE tbl_account"))
yield engine
await engine.dispose()

147
backend/crud/chat_crud.py Normal file
View File

@ -0,0 +1,147 @@
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Optional, Tuple
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions
from common.enums import ChatSender, ErrorType
from common.logger import LOG
# 협상 채팅 CRUD. 메시지 로그(negotiation.chats)와 종료 시 세션 입찰 확정(negotiation.sessions)을 다룬다.
# chats / sessions 모두 NEGOTIATION 논리 DB 라 한 트랜잭션(execute_lambda_run)으로 묶을 수 있다.
class IChatCRUD(ABC):
@abstractmethod
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용."""
pass
@abstractmethod
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
pass
@abstractmethod
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
pass
@abstractmethod
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
pass
@abstractmethod
async def finalize_session(
self, cdb: AsyncSession, session_id, status: int,
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
) -> ErrorType:
pass
class ChatCRUD(IChatCRUD):
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
try:
# (session_id, seq) 유니크 인덱스가 정렬 스캔을 커버한다.
query = (
select(chats)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(asc(chats.seq))
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_session failed.")
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, rows
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
try:
query = (
select(chats.seq, chats.sender)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(desc(chats.seq))
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.")
if err_type != ErrorType.SUCCESS:
return err_type, (0, None)
if not rows:
return ErrorType.SUCCESS, (0, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, (0, None)
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, message, raise_error=False)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
# agent 실패 시 선점(pre-claim)한 유저 메시지를 되돌린다. 부분 유니크(WHERE deleted=FALSE)라 seq 가 다시 비워진다.
try:
query = update(chats).where(chats.chat_id == chat_id).values(deleted=True)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
# mock agent 진행(turn) 계산용. 실제 agent 는 자체 세션 상태로 진행하므로 무시한다.
try:
query = select(func.count()).select_from(chats).where(
chats.session_id == session_id,
chats.sender == ChatSender.BOT.value,
chats.deleted == False, # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_bot_messages failed.")
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (rows[0] if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
try:
query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_item_by_id({item_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def finalize_session(
self, cdb: AsyncSession, session_id, status: int,
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
) -> ErrorType:
try:
values = {"status": status}
if bid_price is not None:
values["bid_price"] = bid_price
values["bid_at"] = datetime.now(timezone.utc)
if reject_reason is not None:
values["reject_reason"] = reject_reason[:255]
if reject_price is not None:
values["reject_price"] = reject_price
query = update(sessions).where(sessions.session_id == session_id).values(**values)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -0,0 +1,156 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items, quotations, sessions
from common.enums import ErrorType
from common.logger import LOG
# 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다.
# 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용).
class ISessionCRUD(ABC):
@abstractmethod
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
pass
@abstractmethod
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
pass
@abstractmethod
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
pass
@abstractmethod
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
pass
@abstractmethod
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
pass
class SessionCRUD(ISessionCRUD):
@staticmethod
def __filters(supplier_id, status, qt_type):
conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712
if status is not None:
conds.append(sessions.status == status)
if qt_type is not None:
conds.append(sessions.qt_type == qt_type)
return conds
async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit) -> Tuple[ErrorType, list]:
try:
conds = self.__filters(supplier_id, status, qt_type)
order_col = desc(quotations.end_time) if order == "desc" else asc(quotations.end_time)
query = (
select(
sessions.session_id,
sessions.status,
sessions.qt_type,
sessions.qt_number,
quotations.end_time, # qt_end_time = 견적 마감 시각
items.code,
items.name,
items.model_name,
items.manufacturer,
)
.join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
.order_by(order_col)
.offset(offset)
.limit(limit)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_supplier failed.")
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, rows
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def count_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type) -> Tuple[ErrorType, int]:
try:
conds = self.__filters(supplier_id, status, qt_type)
query = (
select(func.count())
.select_from(sessions)
.join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(*conds, items.deleted == False, quotations.deleted == False) # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_by_supplier failed.")
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (rows[0] if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_session_by_id(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, sessions]:
try:
query = select(sessions).where(sessions.session_id == session_id, sessions.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_session_by_id({session_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_quotation_by_id(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, quotations]:
try:
query = select(quotations).where(quotations.qt_id == quotation_id, quotations.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_quotation_by_id({quotation_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_session_status(self, cdb: AsyncSession, session_id, status: int) -> ErrorType:
try:
query = update(sessions).where(sessions.session_id == session_id).values(status=status)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_quotation_status(self, cdb: AsyncSession, quotation_id, status: int) -> ErrorType:
try:
query = update(quotations).where(quotations.qt_id == quotation_id).values(status=status)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
try:
query = (
update(sessions)
.where(sessions.session_id == session_id)
.values(status=status, reject_reason=reject_reason)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import tbl_account
from common.enums import ErrorType
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers
from common.enums import ErrorType, TokenType
from common.logger import LOG
from common.utils.gtime import GTime
@ -14,29 +14,58 @@ from common.utils.gtime import GTime
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
# - 유저는 supplier_users 테이블, 공급사명은 partner.suppliers 에서 조회한다(no-FK).
class IUserCRUD(ABC):
@abstractmethod
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
async def get_account_by_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, supplier_users]:
pass
@abstractmethod
async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType:
async def get_account_by_su_id(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, supplier_users]:
pass
@abstractmethod
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType:
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
pass
@abstractmethod
async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType:
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
pass
@abstractmethod
async def add_account(self, cdb: AsyncSession, account: supplier_users) -> ErrorType:
pass
@abstractmethod
async def add_token(self, cdb: AsyncSession, token: supplier_user_tokens) -> ErrorType:
pass
@abstractmethod
async def get_token(self, cdb: AsyncSession, su_id, token_type: int) -> Tuple[ErrorType, str]:
pass
@abstractmethod
async def delete_tokens_by_su_id(self, cdb: AsyncSession, su_id) -> ErrorType:
pass
@abstractmethod
async def update_access_token(self, cdb: AsyncSession, su_id, token, issued_at, expired_at) -> ErrorType:
pass
@abstractmethod
async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType:
pass
class UserCRUD(IUserCRUD):
async def get_account_by_id(self, cdb: AsyncSession, user_id: str) -> Tuple[ErrorType, tbl_account]:
async def get_account_by_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, supplier_users]:
try:
query = select(tbl_account).where(tbl_account.id == user_id).limit(1)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_id(ID:{user_id}) failed.")
query = (
select(supplier_users)
.where(supplier_users.id == login_id, supplier_users.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_id(ID:{login_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
@ -46,9 +75,47 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def is_account(self, cdb: AsyncSession, user_id: str) -> ErrorType:
async def get_account_by_su_id(self, cdb: AsyncSession, su_id) -> Tuple[ErrorType, supplier_users]:
try:
query = select(tbl_account).where(tbl_account.id == user_id).limit(1)
query = (
select(supplier_users)
.where(supplier_users.su_id == su_id, supplier_users.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_account_by_su_id(su_id:{su_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
try:
query = (
select(suppliers.name)
.where(suppliers.supplier_id == supplier_id, suppliers.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_supplier_name(supplier_id:{supplier_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
try:
query = (
select(supplier_users)
.where(supplier_users.id == login_id, supplier_users.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type
@ -59,16 +126,71 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def add_account(self, cdb: AsyncSession, account: tbl_account) -> ErrorType:
async def add_account(self, cdb: AsyncSession, account: supplier_users) -> ErrorType:
try:
return await DB_SESSION_MNG.insert(cdb, account)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_last_login(self, cdb: AsyncSession, user_uid: int) -> ErrorType:
async def add_token(self, cdb: AsyncSession, token: supplier_user_tokens) -> ErrorType:
try:
query = update(tbl_account).where(tbl_account.uid == user_uid).values(last_login_at=GTime.UTC())
return await DB_SESSION_MNG.insert(cdb, token)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def get_token(self, cdb: AsyncSession, su_id, token_type: int) -> Tuple[ErrorType, str]:
# 저장된 토큰(jwt 문자열)을 반환한다. stateful 검증(제시 토큰 ↔ 저장 토큰 대조)용.
try:
query = (
select(supplier_user_tokens.token["jwt"].astext)
.where(
supplier_user_tokens.su_id == su_id,
supplier_user_tokens.type == token_type,
supplier_user_tokens.deleted == False, # noqa: E712
)
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def delete_tokens_by_su_id(self, cdb: AsyncSession, su_id) -> ErrorType:
# 단일 세션: 로그인/로그아웃 시 해당 유저의 토큰 행을 모두 제거한다(하드 삭제, 누적 방지).
try:
query = delete(supplier_user_tokens).where(supplier_user_tokens.su_id == su_id)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_access_token(self, cdb: AsyncSession, su_id, token, issued_at, expired_at) -> ErrorType:
# 재발급 시 저장된 access 행만 새 토큰으로 갱신한다.
try:
query = (
update(supplier_user_tokens)
.where(
supplier_user_tokens.su_id == su_id,
supplier_user_tokens.type == TokenType.ACCESS.value,
supplier_user_tokens.deleted == False, # noqa: E712
)
.values(token=token, issued_at=issued_at, expired_at=expired_at)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def update_last_accessed(self, cdb: AsyncSession, su_id) -> ErrorType:
try:
query = update(supplier_users).where(supplier_users.su_id == su_id).values(last_accessed_at=GTime.UTC())
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)

View File

@ -1,74 +1,101 @@
"""Negosium 인증 서버 부하 테스트.
"""인증 서버 부하 테스트 (self-register 방식, 사전 시드 불필요).
실행:
pip install locust
locust -f loadtest/locustfile.py --host http://localhost:9300
# 웹 UI: http://localhost:8089 에서 사용자 수/spawn rate 입력
각 가상 유저가 on_start 에서 자기 계정을 생성(/create)하고 로그인한 뒤,
me/login/refresh/healthz 를 가중치대로 반복한다.
# 헤드리스(CI) 예시 - 100 VU, 10/s 증가, 2분:
locust -f loadtest/locustfile.py --host http://localhost:9300 \
--headless -u 100 -r 10 -t 2m
supplier 의 /create 는 supplier_id(소속 공급사)가 필수이므로, run_local_locust.sh 가
부하용 공급사(부하테스트공급사) 1건을 보장하고 그 supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다.
직접 실행 시:
LOAD_SUPPLIER_ID=<공급사 uuid> locust -f loadtest/locustfile.py --host http://localhost:9300
정리(테스트 후, self-register 로 쌓인 계정 삭제):
psql ... -c "DELETE FROM supplier.supplier_users WHERE id LIKE 'load_user_%';"
주의:
- /login 은 bcrypt 검증(CPU 바운드)이 들어가 가장 무겁다. RPS 가 낮으면
거의 확실히 bcrypt cost 가 병목이다 (DB 아님).
- 부하를 올리며 서버 측에서 PostgreSQL 커넥션 수를 함께 모니터링하라:
SELECT count(*) FROM pg_stat_activity;
(pool_size + max_overflow) x 2(R/W) x 워커수 가 max_connections 를 넘으면
max_connections 초과 시 실패한다.
- /create, /login 은 bcrypt(CPU 바운드) + DB read/write 라 가장 무겁다.
- /me, /refresh 는 su_id DB 존재/활성 검증(read 2회)을 한다 — 순수 JWT 경로가 아니다.
- 논리 DB 가 USER/PARTNER 2개라 같은 negosium_db 에 엔진 풀이 4벌(R/W×2) 잡힌다.
SELECT count(*) FROM pg_stat_activity WHERE datname = 'negosium_db';
"""
import os
import random
from locust import HttpUser, between, events, task
LOAD_SUPPLIER_ID = os.environ.get("LOAD_SUPPLIER_ID", "")
LOAD_PW = "loadpw1234"
# 각 시뮬레이션 유저는 고유 계정을 만들어 로그인 흐름을 반복한다.
class AuthUser(HttpUser):
wait_time = between(0.5, 2.0)
def on_start(self):
# 유저별 고유 계정 생성 후 1회 로그인하여 토큰 확보.
self.user_id = f"load_{random.randint(0, 1_000_000_000)}"
self.password = "pw1234"
self.token = None
self.client.post(
# 유저마다 고유 계정을 생성(self-register)하고 로그인해 토큰을 확보한다.
self.login_id = f"load_user_{random.randint(0, 1_000_000_000)}"
self.access_token = None
self.refresh_token = None
with self.client.post(
"/v1/auth/create",
json={"id": self.user_id, "pw": self.password, "nickname": "load"},
json={"supplier_id": LOAD_SUPPLIER_ID, "id": self.login_id, "pw": LOAD_PW},
name="POST /v1/auth/create",
)
catch_response=True,
) as resp:
if resp.status_code == 200 and resp.json().get("result", {}).get("success"):
resp.success()
else:
resp.failure(f"create failed: {resp.status_code} {resp.text[:120]}")
self._login()
def _login(self):
with self.client.post(
"/v1/auth/login",
json={"id": self.user_id, "pw": self.password},
json={"id": self.login_id, "pw": LOAD_PW},
name="POST /v1/auth/login",
catch_response=True,
) as resp:
if resp.status_code == 200 and resp.json().get("result", {}).get("success"):
self.token = resp.json().get("access_token")
body = resp.json()
self.access_token = body.get("access_token")
self.refresh_token = body.get("refresh_token")
resp.success()
else:
resp.failure(f"login failed: {resp.status_code} {resp.text[:120]}")
@task(5)
def me(self):
# JWT 검증만 하는 경량 경로 (DB 無). bcrypt 경로와 처리량 비교용.
if not self.token:
# 토큰 검증 + su_id/공급사명 DB 조회(2 read). 보호 엔드포인트 처리량 측정.
if not self.access_token:
return
self.client.get(
"/v1/auth/me",
headers={"Authorization": f"Bearer {self.token}"},
headers={"Authorization": f"Bearer {self.access_token}"},
name="GET /v1/auth/me",
)
@task(2)
def login(self):
# bcrypt + DB write 가 포함된 무거운 경로.
# bcrypt + DB read 2 + DB write 가 포함된 무거운 경로.
self._login()
@task(1)
def refresh(self):
# refresh 토큰 검증 + su_id DB 존재/활성 확인 후 access 재발급.
if not self.refresh_token:
return
with self.client.post(
"/v1/auth/refresh_token",
headers={"Authorization": f"Bearer {self.refresh_token}"},
name="POST /v1/auth/refresh_token",
catch_response=True,
) as resp:
if resp.status_code == 200 and resp.json().get("result", {}).get("success"):
self.access_token = resp.json().get("access_token")
resp.success()
else:
resp.failure(f"refresh failed: {resp.status_code} {resp.text[:120]}")
@task(1)
def healthz(self):
# 베이스라인 (앱 오버헤드 측정).
@ -77,4 +104,6 @@ class AuthUser(HttpUser):
@events.test_start.add_listener
def _on_start(environment, **kwargs):
print("부하 테스트 시작 - PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)")
if not LOAD_SUPPLIER_ID:
print("[warn] LOAD_SUPPLIER_ID 가 비어있습니다 — /create 가 전부 실패합니다. run_local_locust.sh 로 실행하세요.")
print("부하 테스트 시작 - self-register 방식. PostgreSQL 커넥션 수 모니터링 권장 (pg_stat_activity)")

View File

@ -1,8 +1,10 @@
fastapi
uvicorn[standard]
sqlalchemy>=2.0
greenlet # SQLAlchemy async 의 sync/async 브리지에 필수 (일부 환경에서 자동 설치 누락됨)
asyncpg
python-jose[cryptography]
bcrypt
orjson
pydantic>=2.0
httpx # agent(협상 에이전트) 호출용 async HTTP 클라이언트

View File

@ -2,12 +2,16 @@ import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
import router.v1.auth.account
import router.v1.negotiation.session
import router.v1.negotiation.chat
API_SERVER_START_TIME = GTime.UTCStr()
@ -22,6 +26,17 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Negosium Api Server", lifespan=lifespan)
# CORS: config 의 cors_origins 가 있을 때만 적용(브라우저 프론트 호출 허용).
# 명시적 오리진을 쓰므로 allow_credentials=True 가능(쿠키/Authorization 헤더 허용).
if web_server_config.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=web_server_config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Accept-Encoding: gzip 요청에 대해 1000 bytes 이상 응답을 압축.
app.add_middleware(GZipMiddleware, minimum_size=1000)
@ -42,3 +57,5 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.negotiation.session.router)
app.include_router(router.v1.negotiation.chat.router)

View File

@ -1,12 +1,15 @@
from fastapi import APIRouter, Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from router.v1.validator.dependencies import (
IsValidAccessToken,
IsValidRefreshToken,
RemoveNoneResponse,
security,
)
from services.auth_service import AuthService
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_RefreshToken
security = HTTPBearer()
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
@ -17,26 +20,49 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends
return RemoveNoneResponse(await service.attempt_login(req.id, req.pw, request.client.host))
@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.")
# TODO: 계정 생성은 관리자/매니저 권한으로 제한할 가능성이 있음(현재는 비보호).
@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="supplier_id 소속의 유저를 생성한다(공급사 존재를 앱에서 검증).")
async def create_account(request: Request, req: Req_CreateAccount, service: AuthService = Depends()):
return RemoveNoneResponse(await service.create_account(req.id, req.pw, req.nickname, request.client.host))
return RemoveNoneResponse(
await service.create_account(
req.supplier_id, req.id, req.pw, req.name, req.email, req.contact_number, req.role, request.client.host
)
)
@router.post(
path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)],
response_model=Res_RefreshToken,
summary="액세스 토큰 갱신",
description="refresh 토큰으로 access 토큰을 재발급한다.",
description="refresh 토큰으로 access 토큰을 재발급한다. su_id DB 존재/활성 + 저장 토큰 대조를 service 에서 확인한다.",
)
async def refresh_token(service: AuthService = Depends(), credentials: HTTPAuthorizationCredentials = Depends(security)):
return RemoveNoneResponse(await service.refresh_token(credentials.credentials))
async def refresh_token(
user_info: UserInfo = Depends(IsValidRefreshToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: AuthService = Depends(),
):
return RemoveNoneResponse(await service.refresh_token(user_info, credentials.credentials))
@router.post(
path="/logout",
response_model=Res_Logout,
summary="로그아웃",
description="저장된 access/refresh 토큰을 폐기한다. 이후 보호 요청·재발급이 차단된다(단일 세션).",
)
async def logout(user_info: UserInfo = Depends(IsValidAccessToken), service: AuthService = Depends()):
return RemoveNoneResponse(await service.logout(user_info))
@router.get(
path="/me",
summary="내 정보 (보호된 엔드포인트 예시)",
description="유효한 access 토큰이 있어야 호출 가능. 토큰 검증 결과 UserInfo 를 주입받는다.",
response_model=Res_Me,
summary="내 정보 (보호된 엔드포인트)",
description="access 토큰 검증(validator) 후 su_id DB 존재/활성 + 저장 토큰 대조를 service 에서 확인해 반환한다.",
)
async def me(user_info: UserInfo = Depends(IsValidAccessToken)):
return {"uid": user_info.uid, "id": user_info.id, "nickname": user_info.nickname}
async def me(
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: AuthService = Depends(),
):
return RemoveNoneResponse(await service.get_me(user_info, credentials.credentials))

View File

@ -1,5 +1,3 @@
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -14,21 +12,41 @@ class Req_Login(AuthProtocol):
class Res_Login(Res_WebPacketProtocol):
uid: int = Field(0, description="user uid", json_schema_extra={"format": "int64"})
nickname: str = ""
su_id: str = ""
name: str = "" # 유저 개인 이름
supplier_id: str = "" # 소속 공급사(partner.suppliers)
supplier_name: str = "" # 공급사명
role: int = 0
access_token: str = ""
refresh_token: str = ""
class Req_CreateAccount(AuthProtocol):
id: str = ""
supplier_id: str = "" # 소속 공급사(partner.suppliers.supplier_id)
id: str = "" # 로그인 ID
pw: str = ""
nickname: str = ""
name: str = ""
email: str = ""
contact_number: str = ""
role: int = 1 # 1=user, 2=manager (UserRole)
class Res_CreateAccount(Res_WebPacketProtocol):
uid: int = Field(0, description="생성된 user uid", json_schema_extra={"format": "int64"})
su_id: str = ""
class Res_RefreshToken(Res_WebPacketProtocol):
access_token: str = ""
class Res_Me(Res_WebPacketProtocol):
su_id: str = ""
id: str = ""
name: str = ""
supplier_id: str = ""
supplier_name: str = ""
role: int = 0
class Res_Logout(Res_WebPacketProtocol):
pass

View File

@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends
from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.chat_service import ChatService
from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
@router.get(
path="/sessions/{session_id}/chat/init",
response_model=Res_ChatInit,
summary="채팅 진입(상품·견적 메타)",
description="채팅 화면 진입용. 상품/견적 정보 + 현재 세션 상태 + 마감 시각(타이머)을 반환한다. 소유(공급사) 검증.",
)
async def chat_init(
session_id: str,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(await service.init(user_info, credentials.credentials, session_id))
@router.get(
path="/sessions/{session_id}/chat/messages",
response_model=Res_ChatMessages,
summary="대화 히스토리",
description="세션의 대화 말풍선 목록(seq 오름차순). 비어 있고 협상중이면 오프닝 메시지를 생성해 포함한다.",
)
async def chat_messages(
session_id: str,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(await service.messages(user_info, credentials.credentials, session_id))
@router.post(
path="/sessions/{session_id}/chat/send",
response_model=Res_ChatSend,
summary="협상 한 턴 전송",
description="유저 입력을 보내고 agent 가 만든 봇 응답 1건을 반환한다(append-only). 종료 시 세션 입찰을 확정한다.",
)
async def chat_send(
session_id: str,
req: Req_ChatSend,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: ChatService = Depends(),
):
return RemoveNoneResponse(
await service.send(user_info, credentials.credentials, session_id, req.user_input_type, req.user_input)
)

View File

@ -0,0 +1,66 @@
"""채팅(chat) 라우터 프로토콜 — backend ↔ 프론트 계약.
agent Res_Chat → 이 ChatMessage 매핑:
step→step, client_step→display_step, script→script,
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end.
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None).
"""
from typing import Optional
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
# 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다.
class ChatMessage(WebPacketProtocol):
chat_id: str = ""
session_id: str = ""
seq: int = 0
sender: int = 0 # ChatSender 코드
script: str = ""
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price
step: str = ""
display_step: str = "" # agent client_step
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
chat_end: bool = False
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
class Res_ChatInit(Res_WebPacketProtocol):
session_id: str = ""
session_status: int = 0 # SessionStatus 코드
quotation_id: str = ""
quotation_end_time: str = "" # ISO 8601 (마감 시각)
quotation_memo: str = ""
item_id: str = ""
item_name: str = ""
item_code: str = ""
item_image: str = ""
item_price: int = 0
item_model_name: str = ""
item_maker_name: str = ""
item_spec: str = ""
item_lead_time: str = ""
item_min_order_quantity: str = ""
item_vat_yn: Optional[bool] = None
item_delivery_fee_yn: Optional[bool] = None
# 대화 히스토리(재진입 복원)
class Res_ChatMessages(Res_WebPacketProtocol):
items: list[ChatMessage] = []
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
class Req_ChatSend(WebPacketProtocol):
user_input_type: Optional[str] = None # text|percent|price
user_input: str = ""
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
class Res_ChatSend(Res_WebPacketProtocol):
message: Optional[ChatMessage] = None
session_status: int = 0

View File

@ -0,0 +1,33 @@
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
# 협상 세션 목록 행. status/qt_type 은 정수 코드로 내려가고 라벨 매핑은 프론트가 한다.
class ListItem(WebPacketProtocol):
session_id: str = ""
session_status: int = 0 # SessionStatus 코드
qt_type: int = 0 # QtType 코드 (1=재협상, 2=재견적)
qt_number: str = ""
qt_end_time: str = "" # ISO 8601 (마감 시각)
item_code: str = ""
item_name: str = ""
model_name: str = ""
maker_name: str = ""
class Res_SessionList(Res_WebPacketProtocol):
items: list[ListItem] = []
total: int = 0
page: int = 0
page_size: int = 0
class Res_Participate(Res_WebPacketProtocol):
session_id: str = "" # 참여 성공한 세션 (채팅 진입용)
class Req_Reject(WebPacketProtocol):
reject_reason: str = "" # 거부 사유 (단종/품절 프리셋 라벨 또는 기타 직접 입력)
class Res_Reject(Res_WebPacketProtocol):
session_id: str = "" # 거부 처리된 세션

View File

@ -0,0 +1,63 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.negotiation_service import NegotiationService
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
@router.get(
path="/sessions",
response_model=Res_SessionList,
summary="협상 세션 목록",
description="로그인한 공급사의 협상 세션 목록. 필터(status/qt_type, 정수 코드)·마감일 정렬·페이지네이션 지원.",
)
async def list_sessions(
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"),
qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적)"),
order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
return RemoveNoneResponse(
await service.list_sessions(user_info, credentials.credentials, status, qt_type, order, page, page_size)
)
@router.post(
path="/sessions/{session_id}/participate",
response_model=Res_Participate,
summary="협상 참여",
description="세션에 참여한다. 소유(공급사)·세션상태·견적마감·마감시간 검증 후 협상생성→협상중, 견적→견적진행중으로 전이.",
)
async def participate(
session_id: str,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(await service.participate(user_info, credentials.credentials, session_id))
@router.post(
path="/sessions/{session_id}/reject",
response_model=Res_Reject,
summary="협상 거부",
description="세션 참여를 거부한다. 소유(공급사)·세션상태(완료/미참여/거부 불가)·견적마감·마감시간 검증 후 협상거부로 전이하고 사유를 저장.",
)
async def reject(
session_id: str,
req: Req_Reject,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))

View File

@ -88,8 +88,7 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
# ---- Depends 용 토큰 검증기 ------------------------------------------------
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
# ---- Depends 용 토큰 검증기 (디코드만; DB 존재/활성 검증은 service 가 담당) -----
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
return DecodeAccessToken(credentials.credentials)

88
backend/run_local_locust.sh Executable file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# 로컬 부하테스트(locust) 실행 (대화형). 실행하면 파일/시드/방식을 골라 입력한다.
# loadtest/ 아래 locust 파일이 늘어나도 목록에서 선택만 하면 된다.
#
set -euo pipefail
cd "$(dirname "$0")" # backend/
VENV=".venv"
LOCUST="$VENV/bin/locust"
HOST="${LOCUST_HOST:-http://localhost:9300}"
# 시드용 DB 접속 (local 기본값, 환경변수로 override 가능)
PGHOST="${PGHOST:-127.0.0.1}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"
PGDATABASE="${PGDATABASE:-negosium_db}"
# locust 설치 보장
if [[ ! -x "$LOCUST" ]]; then
echo "[setup] locust 설치..."
"$VENV/bin/python" -m pip install -q locust
fi
# 1) locust 파일 선택
FILES=()
while IFS= read -r f; do
FILES+=("$f")
done < <(ls -1 loadtest/*.py 2>/dev/null | grep -v __pycache__ || true)
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "[error] loadtest/ 에 locust 파일이 없습니다."
exit 1
fi
echo "── locust 파일 선택 ──"
i=1
for f in "${FILES[@]}"; do
echo " $i) ${f#loadtest/}"
i=$((i + 1))
done
read -rp "선택 [1]: " fsel
fsel="${fsel:-1}"
FILE="${FILES[$((fsel - 1))]:-}"
if [[ -z "$FILE" ]]; then
echo "[error] 잘못된 선택: $fsel"
exit 1
fi
# 2) 부하용 공급사 1건 보장 → supplier_id 를 LOAD_SUPPLIER_ID 로 넘긴다.
# 유저는 사전 시드하지 않고 locust on_start 에서 self-register 한다(tbl 방식).
# (없으면 만들고, 있으면 그대로 사용 = get-or-create)
LOAD_SUPPLIER_ID="$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -A -c "
WITH ex AS (
SELECT supplier_id FROM partner.suppliers WHERE name='부하테스트공급사' AND deleted=false LIMIT 1
), ins AS (
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name)
SELECT gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '부하테스트공급사'
WHERE NOT EXISTS (SELECT 1 FROM ex)
RETURNING supplier_id
)
SELECT supplier_id FROM ins UNION ALL SELECT supplier_id FROM ex LIMIT 1;
" 2>/dev/null | tr -d '[:space:]')"
if [[ -z "$LOAD_SUPPLIER_ID" ]]; then
echo "[error] 부하용 공급사 supplier_id 를 확보하지 못했습니다 (DB 접속/스키마 확인)."
exit 1
fi
# 이전 실행에서 self-register 로 쌓인 계정/토큰 정리(누적 방지). 공급사 행은 재사용하므로 남긴다.
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -q \
-c "DELETE FROM supplier.supplier_user_tokens WHERE su_id IN (SELECT su_id FROM supplier.supplier_users WHERE id LIKE 'load_user_%');
DELETE FROM supplier.supplier_users WHERE id LIKE 'load_user_%';" >/dev/null 2>&1 || true
echo "[info] 부하용 공급사 supplier_id=$LOAD_SUPPLIER_ID (유저는 self-register, 이전 부하계정 정리됨)"
# 3) 실행 방식 선택
echo "── 실행 방식 ──"
echo " 1) 웹 UI (브라우저에서 사용자 수 조절, http://localhost:8089)"
echo " 2) 헤드리스 (값 입력)"
read -rp "선택 [1]: " mode
mode="${mode:-1}"
if [[ "$mode" == "2" ]]; then
read -rp "동시 사용자 수 [50]: " VU; VU="${VU:-50}"
read -rp "초당 증가 수 [10]: " RATE; RATE="${RATE:-10}"
read -rp "지속 시간(예 30s/2m) [1m]: " DUR; DUR="${DUR:-1m}"
echo "[run] $FILE headless -u $VU -r $RATE -t $DUR (host=$HOST)"
exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST" --headless -u "$VU" -r "$RATE" -t "$DUR"
else
echo "[run] $FILE 웹 UI → http://localhost:8089 (host=$HOST)"
exec env LOAD_SUPPLIER_ID="$LOAD_SUPPLIER_ID" "$LOCUST" -f "$FILE" --host "$HOST"
fi

62
backend/run_local_pgwatch.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# PostgreSQL 커넥션 모니터 (로그 방식). negosium_db 커넥션을 한 줄씩 쌓아가며 본다.
# 부하테스트(run_local_locust.sh) 중 별도 터미널에서 띄워 풀 사용량 추세를 관찰한다.
# (화면을 덮어쓰지 않으므로 스크롤로 이력을 그대로 볼 수 있다)
#
set -uo pipefail
cd "$(dirname "$0")" # backend/
# DB 접속 (local 기본값, 환경변수로 override 가능)
PGHOST="${PGHOST:-127.0.0.1}"
PGPORT="${PGPORT:-5432}"
PGUSER="${PGUSER:-postgres}"
PGDATABASE="${PGDATABASE:-negosium_db}"
q() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" "$@"; }
# 접속 확인
if ! q -tAc "SELECT 1" >/dev/null 2>&1; then
echo "[error] DB 접속 실패: $PGUSER@$PGHOST:$PGPORT/$PGDATABASE"
exit 1
fi
MAXCONN="$(q -tAc "SHOW max_connections;" 2>/dev/null | tr -d '[:space:]')"
MAXCONN="${MAXCONN:-0}"
read -rp "갱신 간격(초) [1]: " ITV; ITV="${ITV:-1}"
read -rp "로그 파일로도 저장 (경로, 비우면 화면만): " LOGF
echo "DB=$PGDATABASE max_connections=$MAXCONN (Ctrl+C 로 종료)"
echo " - total: 전체 커넥션 / active: 실행 중 / idle: 풀 유휴 / idle_tx: 트랜잭션 유휴(누수 의심)"
HEADER="시각 total active idle idle_tx"
echo "$HEADER"
[[ -n "$LOGF" ]] && { echo "# $HEADER" >>"$LOGF"; }
emit() { # 화면 + (옵션)파일
echo "$1"
[[ -n "$LOGF" ]] && echo "$1" >>"$LOGF"
}
# 우리 앱 풀 산식(참고): USER/PARTNER × R/W = 엔진 4벌 × (pool_size+max_overflow) = 최대 120 / 워커
while true; do
TS="$(date '+%H:%M:%S')"
# 상태별 카운트를 한 쿼리로(모니터 자신은 제외). 출력: "total active idle idle_tx"
ROW="$(q -tAF' ' -c "SELECT count(*),
count(*) FILTER (WHERE state='active'),
count(*) FILTER (WHERE state='idle'),
count(*) FILTER (WHERE state='idle in transaction')
FROM pg_stat_activity
WHERE datname='$PGDATABASE' AND pid <> pg_backend_pid();" 2>/dev/null)"
read -r TOTAL ACTIVE IDLE IDLETX <<<"${ROW:-0 0 0 0}"
TOTAL="${TOTAL:-0}"; ACTIVE="${ACTIVE:-0}"; IDLE="${IDLE:-0}"; IDLETX="${IDLETX:-0}"
PCT=0
if [[ "$MAXCONN" =~ ^[0-9]+$ && "$MAXCONN" -gt 0 ]]; then PCT=$(( TOTAL * 100 / MAXCONN )); fi
WARN=""
if (( PCT >= 80 )); then WARN=" <- max 임박!"; fi
emit "$(printf '%s %3s/%s (%3s%%) %5s %4s %4s%s' \
"$TS" "$TOTAL" "$MAXCONN" "$PCT" "$ACTIVE" "$IDLE" "$IDLETX" "$WARN")"
sleep "$ITV"
done

57
backend/run_local_server.sh Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# 로컬 백엔드 서버 실행 (대화형). 실행하면 모드를 골라 입력한다.
# 최초 실행 시 venv 생성 + 의존성 설치까지 자동으로 한다.
#
set -euo pipefail
cd "$(dirname "$0")" # backend/
VENV=".venv"
PY="$VENV/bin/python"
PORT=9300
# 1) venv + 의존성 보장
if [[ ! -d "$VENV" ]]; then
echo "[setup] venv 생성 + 의존성 설치..."
python3 -m venv "$VENV"
"$PY" -m pip install -q --upgrade pip
"$PY" -m pip install -q -r requirements.txt
fi
# 2) config 보장
if [[ ! -f config/config.local.toml ]]; then
echo "[error] config/config.local.toml 이 없습니다. 아래로 생성 후 값을 채우세요:"
echo " cp config/config.local.toml.example config/config.local.toml"
exit 1
fi
# 3) 모드 선택
echo "── 실행 모드 선택 ──"
echo " 1) 일반 실행 (web_main.py)"
echo " 2) 자동 재시작 (uvicorn --reload, 개발용)"
echo " 3) 의존성 재설치"
echo " q) 취소"
read -rp "선택 [1]: " choice
choice="${choice:-1}"
case "$choice" in
3) echo "[setup] 의존성 재설치..."; "$PY" -m pip install -q -r requirements.txt; echo "완료"; exit 0 ;;
q|Q) echo "취소합니다."; exit 0 ;;
esac
# 4) 포트 정리 (이미 떠 있으면 종료)
if lsof -ti:"$PORT" >/dev/null 2>&1; then
echo "[info] 포트 $PORT 사용 중 → 기존 프로세스 종료"
lsof -ti:"$PORT" | xargs kill 2>/dev/null || true
sleep 1
fi
export APP_ENV=local
# 5) 실행
case "$choice" in
1) echo "[run] web_main.py → http://localhost:$PORT/docs"
exec "$PY" web_main.py ;;
2) echo "[run] uvicorn --reload → http://localhost:$PORT/docs"
exec "$VENV/bin/uvicorn" router.router:app --host 0.0.0.0 --port "$PORT" --reload ;;
*) echo "[error] 알 수 없는 선택: $choice"; exit 1 ;;
esac

View File

@ -0,0 +1,86 @@
-- 개발/연동 테스트용 시드 데이터.
-- 로그인 계정 1개(공급사 1개) + 협상 세션 6개(상품/견적 조인용)를 채운다.
-- 재실행 안전: 고정 UUID 로 먼저 지운 뒤 다시 넣는다.
--
-- 실행: PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres -d negosium_db -f backend/scripts/dev_seed.sql
--
-- 로그인: id=test01 / pw=1234 (비밀번호는 pgcrypto bcrypt 로 해시 — 백엔드 bcrypt.checkpw 와 호환)
\set ON_ERROR_STOP on
BEGIN;
-- 고정 UUID (재실행 시 식별/삭제용)
-- supplier : a0000000-...-0001
-- dummy fk : e0000000-...-0000 (company_id/user_id 등 no-FK 더미)
-- items : b...001~006 / quotations : c...001~006 / sessions : d...001~006
-- 1) 기존 시드 제거 (하드 삭제)
DELETE FROM supplier.supplier_user_tokens
WHERE su_id IN (SELECT su_id FROM supplier.supplier_users WHERE id = 'test01');
DELETE FROM supplier.supplier_users WHERE id = 'test01';
DELETE FROM negotiation.sessions WHERE supplier_id = 'a0000000-0000-0000-0000-000000000001';
DELETE FROM partner.items WHERE item_id::text LIKE 'b0000000-0000-0000-0000-0000000000%';
DELETE FROM quotation.quotations WHERE qt_id::text LIKE 'c0000000-0000-0000-0000-0000000000%';
DELETE FROM partner.suppliers WHERE supplier_id = 'a0000000-0000-0000-0000-000000000001';
-- 2) 공급사
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name, code)
VALUES (
'a0000000-0000-0000-0000-000000000001',
'e0000000-0000-0000-0000-000000000000',
'e0000000-0000-0000-0000-000000000000',
'테스트공급사(연동)',
'SUP-DEV-01'
);
-- 3) 로그인 계정 (id=test01 / pw=1234)
INSERT INTO supplier.supplier_users
(supplier_id, id, password, name, email, contact_number, last_accessed_at, status, role)
VALUES (
'a0000000-0000-0000-0000-000000000001',
'test01',
crypt('1234', gen_salt('bf', 12)),
'홍길동',
'test01@example.com',
'010-1234-5678',
now(),
1, -- ACTIVE
2 -- MANAGER
);
-- 4) 상품 6개
INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer, price)
VALUES
('b0000000-0000-0000-0000-000000000001','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','사무용 노트북 14인치','IMK-10231','NB-1400-PRO','삼성전자', 1250000),
('b0000000-0000-0000-0000-000000000002','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','레이저 복합기','IMK-10232','MFC-7890DW','브라더', 430000),
('b0000000-0000-0000-0000-000000000003','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','27인치 4K 모니터','IMK-10233','U2723QE','델', 690000),
('b0000000-0000-0000-0000-000000000004','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','무선 기계식 키보드','IMK-10234','MX-KEYS-M','로지텍', 159000),
('b0000000-0000-0000-0000-000000000005','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','A4 무선 레이저프린터','IMK-10235','SL-M2030','HP', 210000),
('b0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','회의실 대형 디스플레이 65인치','IMK-10236','QM65R','삼성전자', 2890000);
-- 5) 견적 6개 (end_time = 마감 진실값)
-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) / status: 1=생성 2=진행중 3=마감
INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time)
VALUES
('c0000000-0000-0000-0000-000000000001','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','노트북 재견적','QT-2026-000101',2,1,1,'2026-06-15 09:00+00','2026-06-20 18:00+00'),
('c0000000-0000-0000-0000-000000000002','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','복합기 재협상','QT-2026-000102',1,1,2,'2026-06-14 09:00+00','2026-06-19 12:30+00'),
('c0000000-0000-0000-0000-000000000003','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','모니터 재견적','QT-2026-000103',2,1,3,'2026-06-10 09:00+00','2026-06-22 09:00+00'),
('c0000000-0000-0000-0000-000000000004','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','키보드 재협상','QT-2026-000104',1,1,2,'2026-06-13 09:00+00','2026-06-21 15:45+00'),
('c0000000-0000-0000-0000-000000000005','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','프린터 재견적','QT-2026-000105',2,1,3,'2026-06-10 09:00+00','2026-06-16 11:00+00'),
('c0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','디스플레이 재견적','QT-2026-000106',2,1,1,'2026-06-16 09:00+00','2026-06-25 17:00+00');
-- 6) 협상 세션 6개 (supplier_id = 로그인 공급사) / 다양한 상태
-- status: 1=협상생성 2=협상중 3=협상완료 4=미참여 5=협상거부
INSERT INTO negotiation.sessions
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time)
VALUES
('d0000000-0000-0000-0000-000000000001','c0000000-0000-0000-0000-000000000001','b0000000-0000-0000-0000-000000000001','a0000000-0000-0000-0000-000000000001','QT-2026-000101',1,2,1180000,1,'2026-06-20 18:00+00'),
('d0000000-0000-0000-0000-000000000002','c0000000-0000-0000-0000-000000000002','b0000000-0000-0000-0000-000000000002','a0000000-0000-0000-0000-000000000001','QT-2026-000102',1,1, 400000,2,'2026-06-19 12:30+00'),
('d0000000-0000-0000-0000-000000000003','c0000000-0000-0000-0000-000000000003','b0000000-0000-0000-0000-000000000003','a0000000-0000-0000-0000-000000000001','QT-2026-000103',1,2, 650000,3,'2026-06-22 09:00+00'),
('d0000000-0000-0000-0000-000000000004','c0000000-0000-0000-0000-000000000004','b0000000-0000-0000-0000-000000000004','a0000000-0000-0000-0000-000000000001','QT-2026-000104',1,1, 150000,5,'2026-06-21 15:45+00'),
('d0000000-0000-0000-0000-000000000005','c0000000-0000-0000-0000-000000000005','b0000000-0000-0000-0000-000000000005','a0000000-0000-0000-0000-000000000001','QT-2026-000105',1,2, 200000,4,'2026-06-16 11:00+00'),
('d0000000-0000-0000-0000-000000000006','c0000000-0000-0000-0000-000000000006','b0000000-0000-0000-0000-000000000006','a0000000-0000-0000-0000-000000000001','QT-2026-000106',1,2,2700000,1,'2026-06-25 17:00+00');
COMMIT;

View File

@ -0,0 +1,153 @@
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
agent 완성 여부와 무관하게 통합 테스트할 수 있다.
agent 응답(Res_Chat) → AgentTurn 매핑:
step, client_step, script, input_mode(=next_input_mode), input_options(=next_input_type),
chat_end, outcome, card_id, indicator_value.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
from common.logger import LOG
from config.server_configs import agent_config
@dataclass
class AgentTurn:
"""agent 한 턴 응답(Res_Chat) 의 backend 표현."""
session_id: Optional[str] = None
step: str = ""
client_step: str = ""
script: str = ""
input_mode: Optional[str] = None # 프론트 next_input_mode 로 매핑
input_options: Optional[list[str]] = None # 프론트 next_input_type 로 매핑
chat_end: bool = False
outcome: Optional[str] = None # "success" | "failure" (종료 시)
card_id: Optional[str] = None
indicator_value: Optional[float] = None
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
@dataclass
class AgentChatContext:
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
extra: dict = field(default_factory=dict)
class IAgentClient(ABC):
@abstractmethod
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
"""협상 한 턴. session_id 없으면 새 세션 시작. user_input 으로 진행(버튼 텍스트/가격)."""
...
class HttpAgentClient(IAgentClient):
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다.
body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
"rq_type": ctx.rq_type,
"user_input": user_input,
"target_price": ctx.target_price,
"anchor_price": ctx.anchor_price,
}
headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
try:
async with httpx.AsyncClient(base_url=agent_config.base_url, timeout=agent_config.timeout_sec) as cli:
resp = await cli.post("/v1/chat", json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
except Exception as ex:
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {ex}")
return AgentTurn(ok=False)
return AgentTurn(
session_id=data.get("session_id"),
step=data.get("step") or "",
client_step=data.get("client_step") or "",
script=data.get("script") or "",
input_mode=data.get("input_mode"),
input_options=data.get("input_options"),
chat_end=bool(data.get("chat_end", False)),
outcome=data.get("outcome"),
card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"),
ok=True,
)
class MockAgentClient(IAgentClient):
"""agent 미연동용 결정론적 mock. ctx.turn(직전 봇 턴 수)으로 협상 단계를 진행한다.
플로우(핵심만): 0=인사(확인) → 1=품목안내(확인) → 2=가격협상(가격입력) → 3+=수락/종료.
"""
_SCRIPT = [
("서비스안내", "협상에 참여해 주셔서 감사합니다. 시작하시겠어요?", "confirm", ["네, 시작할게요"]),
("협상품목안내", "협상 품목을 확인해 주세요. 가격 협상을 진행할까요?", "confirm", ["가격 협상 진행"]),
("가격협상", "희망 공급가를 입력해 주세요.", "price", None),
]
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
sid = session_id or "mock-session"
turn = ctx.turn
# 공급사가 협상 포기/거부 의사를 밝히면 실패로 종료(거부)한다.
if user_input and ("포기" in user_input or "거부" in user_input):
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script="협상이 종료되었습니다.", input_mode=None, input_options=None,
chat_end=True, outcome="failure",
)
if turn < len(self._SCRIPT):
step, script, mode, options = self._SCRIPT[turn]
return AgentTurn(
session_id=sid, step=step, client_step=step, script=script,
input_mode=mode, input_options=options, chat_end=False,
)
# 가격 제시 이후: 목표가 이하면 수락 종료, 아니면 한 번 더 제안 요청
price = _parse_price(user_input)
if price is not None and ctx.target_price and price <= ctx.target_price:
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script=f"제안하신 {price:,}원으로 합의되었습니다. 감사합니다.",
input_mode=None, input_options=None, chat_end=True, outcome="success",
card_id="NGC-MOCK", indicator_value=100.0,
)
return AgentTurn(
session_id=sid, step="가격협상", client_step="가격협상",
script="조금 더 조정된 가격을 제안해 주시겠어요?",
input_mode="price", input_options=None, chat_end=False,
card_id="NGC-MOCK", indicator_value=50.0,
)
def _parse_price(text: Optional[str]) -> Optional[int]:
"""'1,500원' / '1500' 등에서 정수 가격을 파싱한다. 실패 시 None."""
if not text:
return None
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def get_agent_client() -> IAgentClient:
"""config 에 따라 mock/실제 클라이언트를 반환한다(FastAPI Depends 용)."""
return MockAgentClient() if agent_config.use_mock else HttpAgentClient()

View File

@ -1,24 +1,23 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import tbl_account
from common.enums import DBWRType, ErrorType
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers
from common.enums import AccountStatus, DBWRType, ErrorType, TokenType
from common.logger import LOG
from common.models.gmodel import UserInfo
from common.utils.gtime import GTime
from config.server_configs import jwt_token_config
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_RefreshToken
from router.v1.validator.dependencies import (
CreateAccessToken,
CreateRefreshToken,
DecodeRefreshToken,
GetHashedPW,
VerifyPW,
)
from router.v1.auth.protocol import Res_CreateAccount, Res_Login, Res_Logout, Res_Me, Res_RefreshToken
from router.v1.validator.dependencies import CreateAccessToken, CreateRefreshToken, GetHashedPW, VerifyPW
class AuthService:
"""비즈니스 로직 계층 (MVC 의 컨트롤러-서비스 분리에서 서비스).
- 유저는 supplier_users 테이블(JWT subject = UserInfo). uuid 는 문자열로 인코딩.
- CRUD 는 Depends 로 인터페이스 타입으로 주입받는다.
- DB 접근은 DB_SESSION_MNG 의 람다 실행으로만 한다.
조회 = execute_lambda(..., DB_READ, lambda s: crud.xxx(s, ...))
@ -29,57 +28,33 @@ class AuthService:
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
self.user_crud = user_crud
async def attempt_login(self, id: str, pw: str, connect_ip: str) -> Res_Login:
LOG.i(f"LOGIN : {id=}")
res = Res_Login()
# 1) 계정 조회 (Read DB)
err_type, account = await DB_SESSION_MNG.execute_lambda(
tbl_account.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_account_by_id(s, id),
)
if err_type != ErrorType.SUCCESS:
# 계정 없음/조회 실패 모두 로그인 실패로 일반화
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
return res
account: tbl_account
# 2) 비밀번호 검증
if not await VerifyPW(pw, account.pw):
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
return res
# 3) 차단 여부
if account.is_blocked:
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
return res
# 4) 토큰 발급
user_info = UserInfo(uid=account.uid, id=account.id, nickname=account.nickname)
res.access_token = CreateAccessToken(user_info)
res.refresh_token = CreateRefreshToken(user_info)
# 5) 마지막 로그인 시간 갱신 (Write DB, 트랜잭션)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[tbl_account.DBType()],
[lambda s: self.user_crud.update_last_login(s, account.uid)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.uid = account.uid
res.nickname = account.nickname
return res
async def create_account(self, id: str, pw: str, nickname: str, connect_ip: str) -> Res_CreateAccount:
LOG.i(f"CREATE : {id=}, {nickname=}")
async def create_account(
self, supplier_id: str, id: str, pw: str, name: str, email: str, contact_number: str, role: int, connect_ip: str
) -> Res_CreateAccount:
LOG.i(f"CREATE : {id=}, {supplier_id=}")
res = Res_CreateAccount()
# 1) 중복 ID 확인 (Read DB)
# 0) supplier_id 형식 검증
try:
sid = uuid.UUID(supplier_id)
except (ValueError, TypeError):
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
# 1) 공급사 존재 확인 (no-FK 라 앱에서 무결성 검증, PARTNER Read)
err_type, _ = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_supplier_name(s, sid),
)
if err_type != ErrorType.SUCCESS:
# 존재하지 않는 supplier_id
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
# 2) 중복 로그인 ID 확인 (USER Read)
err_type = await DB_SESSION_MNG.execute_lambda(
tbl_account.DBType(),
supplier_users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.is_account(s, id),
)
@ -90,26 +65,224 @@ class AuthService:
res.result.SetResult(err_type)
return res
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
account = tbl_account(id=id, pw=await GetHashedPW(pw), nickname=nickname or id)
# 3) 생성 (pw bcrypt 해시. last_accessed_at 은 NOT NULL/무기본값이라 생성 시각으로 둔다)
account = supplier_users(
supplier_id=sid,
id=id,
password=await GetHashedPW(pw),
name=name or None,
email=email or None,
contact_number=contact_number or None,
last_accessed_at=GTime.UTC(),
status=AccountStatus.ACTIVE.value,
role=role,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[tbl_account.DBType()],
[supplier_users.DBType()],
[lambda s: self.user_crud.add_account(s, account)],
)
if err_type != ErrorType.SUCCESS:
# 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑.
# 사전 검사와 INSERT 사이 경쟁 조건의 unique 위반은 동일 코드로 매핑.
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
else:
res.result.SetResult(err_type)
return res
res.uid = account.uid
res.su_id = str(account.su_id)
return res
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
res = Res_RefreshToken()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.
user_info = DecodeRefreshToken(refresh_token)
async def attempt_login(self, id: str, pw: str, connect_ip: str) -> Res_Login:
LOG.i(f"LOGIN : {id=}")
res = Res_Login()
# 1) 계정 조회 (USER Read 세션)
err_type, account = await DB_SESSION_MNG.execute_lambda(
supplier_users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_account_by_id(s, id),
)
if err_type != ErrorType.SUCCESS:
# 계정 없음/조회 실패 모두 로그인 실패로 일반화
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
return res
account: supplier_users
# 2) 비밀번호 검증
if not await VerifyPW(pw, account.password):
res.result.SetResult(ErrorType.ACCOUNT_INVALID_INFO)
return res
# 3) 상태 확인 (active 만 허용)
if account.status != AccountStatus.ACTIVE.value:
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
return res
# 3-1) 공급사명 조회 (PARTNER Read 세션). 부가 정보라 실패해도 로그인은 막지 않고 빈 값.
_, sname = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_supplier_name(s, account.supplier_id),
)
supplier_name = sname or ""
# 4) 토큰 발급
user_info = UserInfo(
su_id=str(account.su_id),
id=account.id,
name=account.name or "",
supplier_id=str(account.supplier_id),
supplier_name=supplier_name,
role=account.role,
)
res.access_token = CreateAccessToken(user_info)
res.refresh_token = CreateRefreshToken(user_info)
# 5) 마지막 접속 시간 갱신 + 토큰 교체 (Write DB, 한 트랜잭션)
# 단일 세션: 이전 토큰 행을 모두 지우고 access/refresh 2행을 새로 넣어 이전 세션을 무효화한다.
# 저장된 토큰은 추후 로그아웃/검증(토큰 대조)에서 사용한다.
now = GTime.UTC()
access_row = supplier_user_tokens(
su_id=account.su_id,
type=TokenType.ACCESS.value,
token={"jwt": res.access_token},
issued_at=now,
expired_at=GTime.AddMinutes(jwt_token_config.access_expire_min),
)
refresh_row = supplier_user_tokens(
su_id=account.su_id,
type=TokenType.REFRESH.value,
token={"jwt": res.refresh_token},
issued_at=now,
expired_at=GTime.AddDays(jwt_token_config.refresh_expire_day),
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[supplier_users.DBType()],
[
lambda s: self.user_crud.update_last_accessed(s, account.su_id),
lambda s: self.user_crud.delete_tokens_by_su_id(s, account.su_id), # 단일 세션: 이전 토큰 제거
lambda s: self.user_crud.add_token(s, access_row),
lambda s: self.user_crud.add_token(s, refresh_row),
],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.su_id = str(account.su_id)
res.name = account.name or ""
res.supplier_id = str(account.supplier_id)
res.supplier_name = supplier_name
res.role = account.role
return res
async def __load_active_account(self, su_id_str: str) -> tuple[ErrorType, UserInfo]:
"""su_id 로 유저를 조회해 존재 + status=active 확인 후, 공급사명까지 채운 DB 최신값 UserInfo 를
반환한다. (토큰 발급 후 삭제/비활성된 계정 차단용)
실패 시 (에러코드, None) 을 반환하며, HTTP 변환은 라우터가 result 로 내려보낸다.
"""
# 1) 계정 조회 (USER Read 세션)
err_type, account = await DB_SESSION_MNG.execute_lambda(
supplier_users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_account_by_su_id(s, uuid.UUID(su_id_str)),
)
if err_type != ErrorType.SUCCESS or account is None:
return ErrorType.ACCOUNT_INVALID_INFO, None
if account.status != AccountStatus.ACTIVE.value: # active 만 허용
return ErrorType.ACCOUNT_BLOCKED_USER, None
# 2) 공급사명 조회 (PARTNER Read 세션). 부가 정보라 실패해도 빈 값.
_, sname = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_supplier_name(s, account.supplier_id),
)
return ErrorType.SUCCESS, UserInfo(
su_id=str(account.su_id),
id=account.id,
name=account.name or "",
supplier_id=str(account.supplier_id),
supplier_name=sname or "",
role=account.role,
)
async def __verify_stored_token(self, su_id_str: str, token_type: int, presented: str) -> bool:
"""제시된 토큰이 저장된 토큰과 일치하는지 확인한다(stateful 단일 세션).
로그아웃·타기기 로그인으로 교체되면 저장 토큰이 없거나 달라져 False 가 된다.
"""
err_type, stored = await DB_SESSION_MNG.execute_lambda(
supplier_users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_token(s, uuid.UUID(su_id_str), token_type),
)
return err_type == ErrorType.SUCCESS and stored == presented
async def authenticate(self, user_info: UserInfo, access_token: str) -> tuple[ErrorType, UserInfo]:
"""access 토큰 보호 요청 공통 인증: 계정 활성 확인 + 저장된 access 토큰 대조.
성공 시 (SUCCESS, DB 최신 UserInfo), 실패 시 (에러코드, None). 다른 도메인 service 에서도 재사용한다.
"""
err_type, info = await self.__load_active_account(user_info.su_id)
if err_type != ErrorType.SUCCESS:
return err_type, None
if not await self.__verify_stored_token(info.su_id, TokenType.ACCESS.value, access_token):
return ErrorType.TOKEN_REVOKED, None # 로그아웃/타기기 로그인으로 무효화됨
return ErrorType.SUCCESS, info
async def get_me(self, user_info: UserInfo, access_token: str) -> Res_Me:
# 토큰 디코드는 라우터 Depends(IsValidAccessToken) 에서 수행됨. 여기선 공통 인증으로 검증.
res = Res_Me()
err_type, info = await self.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.su_id = info.su_id
res.id = info.id
res.name = info.name
res.supplier_id = info.supplier_id
res.supplier_name = info.supplier_name
res.role = info.role
return res
async def logout(self, user_info: UserInfo) -> Res_Logout:
# 해당 유저의 저장 토큰(access/refresh)을 모두 삭제 → 이후 보호 요청·재발급이 차단된다.
res = Res_Logout()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[supplier_users.DBType()],
[lambda s: self.user_crud.delete_tokens_by_su_id(s, uuid.UUID(user_info.su_id))],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
async def refresh_token(self, user_info: UserInfo, refresh_token: str) -> Res_RefreshToken:
# 토큰 디코드는 라우터 Depends(IsValidRefreshToken) 에서 수행됨. 여기선 su_id DB 검증 + 저장 토큰 대조 후 재발급.
res = Res_RefreshToken()
err_type, info = await self.__load_active_account(user_info.su_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
if not await self.__verify_stored_token(info.su_id, TokenType.REFRESH.value, refresh_token):
res.result.SetResult(ErrorType.TOKEN_REVOKED) # 로그아웃/타기기 로그인으로 무효화됨
return res
new_access = CreateAccessToken(info) # DB 최신값으로 재구성한 토큰
# 단일 세션: 저장된 access 행을 새 토큰으로 갱신한다(refresh 행은 유지).
err_type = await DB_SESSION_MNG.execute_lambda_run(
[supplier_users.DBType()],
[
lambda s: self.user_crud.update_access_token(
s,
uuid.UUID(info.su_id),
{"jwt": new_access},
GTime.UTC(),
GTime.AddMinutes(jwt_token_config.access_expire_min),
)
],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.access_token = new_access
return res

View File

@ -0,0 +1,334 @@
"""ChatService — 채팅 페이지 오케스트레이션.
backend 가 협상 한 턴을 agent(9500) 로 위임하고, 말풍선 로그(negotiation.chats)를 영속화하며,
종료 시 세션 상태(negotiation.sessions)를 전이한다. agent 는 외부 고정 계약(agent_client 어댑터).
- init : 상품/견적 메타 + 현재 세션 상태 (타이머용 마감 시각 포함)
- messages : 대화 히스토리 복원. 비어 있고 협상중이면 agent 오프닝 한 턴을 seed(지연 생성).
- send : (검증 → 유저 메시지 저장 → agent 위임 → 봇 메시지 저장 → 종료 시 입찰 확정) 단일 트랜잭션.
append-only — 새 봇 메시지 1건만 반환(전체 refetch 회피).
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotations, sessions
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.chat_protocol import ChatMessage, Res_ChatInit, Res_ChatMessages, Res_ChatSend
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
from services.auth_service import AuthService
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7
class ChatService:
def __init__(
self,
auth: AuthService = Depends(AuthService),
session_crud: ISessionCRUD = Depends(SessionCRUD),
chat_crud: IChatCRUD = Depends(ChatCRUD),
agent: IAgentClient = Depends(get_agent_client),
):
self.auth = auth
self.session_crud = session_crud
self.chat_crud = chat_crud
self.agent = agent
# ---- 공통 전처리 ----------------------------------------------------
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
return err_type, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
return ErrorType.NEGO_NOT_FOUND, None
err_type, sess = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_session_by_id(s, session_id),
)
if err_type != ErrorType.SUCCESS or sess is None:
return ErrorType.NEGO_NOT_FOUND, None
if str(sess.supplier_id) != info.supplier_id:
return ErrorType.NEGO_FORBIDDEN, None
return ErrorType.SUCCESS, sess
# ---- init -----------------------------------------------------------
async def init(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatInit:
res = Res_ChatInit()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
if err_type != ErrorType.SUCCESS or item is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
# 마감 시간 초과 + 협상생성이면 미참여로 정리 (participate 와 동일 일관성).
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc) and sess.status == SessionStatus.CREATED.value:
await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
)
sess.status = SessionStatus.NOT_PARTICIPATED.value
res.session_id = str(sess.session_id)
res.session_status = sess.status
res.quotation_id = str(sess.quotation_id)
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
res.quotation_memo = quote.memo or ""
res.item_id = str(item.item_id)
res.item_name = item.name or ""
res.item_code = item.code or ""
res.item_image = item.image_url or ""
res.item_price = item.price or 0
res.item_model_name = item.model_name or ""
res.item_maker_name = item.manufacturer or ""
res.item_spec = item.spec or ""
res.item_lead_time = str(item.lead_time) if item.lead_time is not None else ""
res.item_min_order_quantity = item.moq or ""
res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn
return res
# ---- messages -------------------------------------------------------
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
res = Res_ChatMessages()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, rows = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.list_by_session(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
opening = await self._seed_opening(sess)
if opening is not None:
res.items = [opening]
return res
res.items = [self._row_to_message(r) for r in rows]
return res
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
ctx = self._agent_context(sess, turn=0)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
if not turn.ok:
return None
bot = self._build_bot_chat(sess, seq=1, turn=turn)
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
)
return self._chat_to_message(bot)
# ---- send (핵심) ----------------------------------------------------
async def send(self, user_info: UserInfo, access_token: str, session_id_str: str, user_input_type: Optional[str], user_input: str) -> Res_ChatSend:
res = Res_ChatSend()
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 협상중이 아니면 대화 불가
if sess.status != SessionStatus.IN_PROGRESS.value:
res.result.SetResult(ErrorType.CHAT_NOT_IN_PROGRESS)
return res
# 견적 마감/시간 검증
err_type, quote = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
if quote.status == QuotationStatus.CLOSED.value:
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
return res
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc):
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
return res
# 가격 입력이면 범위 검증
price = _parse_price(user_input) if user_input_type == "price" else None
if user_input_type == "price":
if price is None or not _in_price_range(price, sess.target_price):
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 직전이 유저 메시지면 이전 턴이 아직 처리 중(봇 응답 미도착) → 중복 전송 거절
if last_sender == ChatSender.USER.value:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
err_type, turn_no = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.count_bot_messages(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
# 경합에서 밀리면(같은 seq 충돌) agent 를 호출하지 않고 CHAT_IN_PROGRESS 로 거절 → 중복 진행 방지.
user_msg = self._build_user_chat(sess, seq=max_seq + 1, user_input=user_input, user_input_type=user_input_type, price=price)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, user_msg)]
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
ctx = self._agent_context(sess, turn=turn_no)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok:
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
return res
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn)
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
new_status = sess.status
if turn.chat_end:
if turn.outcome == "success":
new_status = SessionStatus.DONE.value
bid = price if price is not None else sess.target_price
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else:
new_status = SessionStatus.REJECTED.value
funcs.append(lambda s: self.chat_crud.finalize_session(
s, sess.session_id, new_status,
reject_reason=(user_input or None), reject_price=price,
))
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
if err_type != ErrorType.SUCCESS:
# 봇 저장 실패 시에도 선점 유저 메시지를 롤백해 stuck(CHAT_IN_PROGRESS) 방지.
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(err_type)
return res
res.message = self._chat_to_message(bot_msg)
res.session_status = new_status
return res
# ---- 빌더 / 매퍼 ----------------------------------------------------
def _agent_context(self, sess, turn: int) -> AgentChatContext:
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 현재 quotation.user_id 만 보유 → 정확한 company_id 해석(company.users 조회)은 agent 연동 시 보완.
tenant_id = "" # mock 은 무시. 실제 연동 시 quotation 의 buyer company_id 로 채운다.
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
)
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.USER.value,
target_price=int(price) if price is not None else 0,
meta={"script": user_input, "user_input_type": user_input_type},
)
def _build_bot_chat(self, sess, seq: int, turn) -> chats:
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.BOT.value,
target_price=int(sess.target_price or 0),
meta={
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
"input_mode": turn.input_mode, "input_options": turn.input_options,
"chat_end": turn.chat_end, "card_id": turn.card_id,
},
)
def _chat_to_message(self, c: chats) -> ChatMessage:
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
meta = c.meta or {}
return ChatMessage(
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
script=meta.get("script") or "",
user_input_type=meta.get("user_input_type"),
step=meta.get("step") or "",
display_step=meta.get("client_step") or "",
next_input_mode=meta.get("input_mode"),
next_input_type=meta.get("input_options"),
chat_end=bool(meta.get("chat_end", False)),
)
def _row_to_message(self, r) -> ChatMessage:
"""DB 행(chats) → 응답 ChatMessage."""
return self._chat_to_message(r)
# ---- 가격 유틸 ----------------------------------------------------------
def _parse_price(text: Optional[str]) -> Optional[int]:
if not text:
return None
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
if not target_price:
return price > 0
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)

View File

@ -0,0 +1,192 @@
import uuid
from datetime import datetime, timezone
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo
from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList
from services.auth_service import AuthService
class NegotiationService:
"""협상 도메인 비즈니스 로직.
- 인증(계정 활성 + 저장 토큰 대조)은 AuthService.authenticate 로 위임(재사용).
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
"""
def __init__(self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD)):
self.auth = auth
self.session_crud = session_crud
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int) -> Res_SessionList:
res = Res_SessionList()
# 1) 인증 (활성 + 저장된 access 토큰 대조)
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
supplier_id = uuid.UUID(info.supplier_id)
offset = (page - 1) * page_size
# 2) 목록 조회 (NEGOTIATION Read 세션, sessions ⨝ items)
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 3) 총개수 (페이지네이션용)
err_type, total = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.items = [
ListItem(
session_id=str(r[0]),
session_status=r[1],
qt_type=r[2],
qt_number=r[3],
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
item_code=r[5] or "",
item_name=r[6] or "",
model_name=r[7] or "",
maker_name=r[8] or "",
)
for r in rows
]
res.total = total
res.page = page
res.page_size = page_size
return res
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
blocked_statuses 에 해당하는 세션 상태면 NEGO_NOT_PARTICIPABLE 로 막는다.
"""
# 1) 인증 (활성 + 저장된 access 토큰 대조)
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
return err_type, None, None
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
return ErrorType.NEGO_NOT_FOUND, None, None
# 2) 세션 조회
err_type, sess = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.session_crud.get_session_by_id(s, session_id),
)
if err_type != ErrorType.SUCCESS or sess is None:
return ErrorType.NEGO_NOT_FOUND, None, None
# 3) 소유 검증 (세션 공급사 == 접속 유저 공급사)
if str(sess.supplier_id) != info.supplier_id:
return ErrorType.NEGO_FORBIDDEN, None, None
# 4) 세션 상태 검증 (호출부가 지정한 불가 상태)
if sess.status in blocked_statuses:
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None
# 5) 견적 조회 + 마감 상태
err_type, quote = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
)
if err_type != ErrorType.SUCCESS or quote is None:
return ErrorType.NEGO_NOT_FOUND, None, None
if quote.status == QuotationStatus.CLOSED.value:
return ErrorType.NEGO_QUOTATION_CLOSED, None, None
# 6) 마감 시간 초과 (견적 end_time < 현재). 협상생성(1)일 때만 session→미참여로 정리.
end = quote.end_time
if end is not None and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if end is not None and end < datetime.now(timezone.utc):
if sess.status == SessionStatus.CREATED.value:
await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
)
return ErrorType.NEGO_DEADLINE_PASSED, None, None
return ErrorType.SUCCESS, sess, quote
async def participate(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Participate:
res = Res_Participate()
# 미참여/협상거부 상태는 참여 불가
err_type, sess, _ = await self._load_actionable_session(
user_info,
access_token,
session_id_str,
(SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 참여 성공 — 협상생성(1)일 때만 상태 전이(협상중/완료는 무변경 진입)
if sess.status == SessionStatus.CREATED.value:
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(sess.session_id)
return res
async def reject(self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str) -> Res_Reject:
res = Res_Reject()
# 거부 사유 필수
reason = (reject_reason or "").strip()[:255]
if not reason:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
# 협상완료/미참여/협상거부 상태는 거부 불가 (참여와 공통 검증 재사용)
err_type, sess, _ = await self._load_actionable_session(
user_info,
access_token,
session_id_str,
(SessionStatus.DONE.value, SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 거부 처리 — 세션을 협상거부로 전이하고 사유 저장
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_reject(s, sess.session_id, SessionStatus.REJECTED.value, reason)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(sess.session_id)
return res

View File

@ -1,63 +1,324 @@
"""auth 도메인 e2e 테스트.
"""인증 e2e 테스트 (supplier_users 기반 유저).
실행 전제: docker-compose 로 PostgreSQL 이 떠 있어야 한다 (negosium_db 사용).
docker compose up -d # 또는 로컬 postgres
실행 전제: PostgreSQL 이 떠 있어야 한다 (negosium_db, supplier/partner 스키마 적용).
cd backend && python -m pytest
테스트는 dev negosium_db 를 그대로 쓰므로, 다른 데이터를 건드리지 않도록
TRUNCATE 대신 전용 테스트 행(pytest_user)만 시드/정리한다.
"""
import uuid
async def test_create_and_login_flow(client):
# 1) 계정 생성
r = await client.post("/v1/auth/create", json={"id": "user1", "pw": "pw1234", "nickname": "닉네임"})
import bcrypt
import pytest_asyncio
from sqlalchemy import text
TEST_LOGIN_ID = "pytest_user"
TEST_PW = "pytest1234"
TEST_USER_NAME = "테스트담당자" # supplier_users.name (유저 개인 이름)
TEST_SUPPLIER_NAME = "파이테스트공급사" # partner.suppliers.name (공급사명)
@pytest_asyncio.fixture
async def account_seed(db_engine):
"""partner.suppliers(공급사) + supplier.supplier_users(유저) 테스트 행을 시드하고, 끝나면 정리한다."""
supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
async def _cleanup(conn):
await conn.execute(
text(
"DELETE FROM supplier.supplier_user_tokens WHERE su_id IN "
"(SELECT su_id FROM supplier.supplier_users WHERE id = :id)"
),
{"id": TEST_LOGIN_ID},
)
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn) # 이전 실행 잔재 제거
await conn.execute(
text(
"INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) "
"VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"
),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text(
"INSERT INTO supplier.supplier_users "
"(supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, :uname, now(), 1, 1)"
),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash, "uname": TEST_USER_NAME},
)
yield {"supplier_id": supplier_id}
# 테스트 중 생성된 유저/토큰까지 정리하기 위해 supplier_id 기준으로 지운다.
async with db_engine.begin() as conn:
await conn.execute(
text(
"DELETE FROM supplier.supplier_user_tokens WHERE su_id IN "
"(SELECT su_id FROM supplier.supplier_users WHERE supplier_id = :sid)"
),
{"sid": supplier_id},
)
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE supplier_id = :sid"), {"sid": supplier_id})
await conn.execute(text("DELETE FROM partner.suppliers WHERE supplier_id = :sid"), {"sid": supplier_id})
async def _set_account(db_engine, **values):
"""테스트용 유저 행의 컬럼을 갱신한다 (status/deleted 등)."""
sets = ", ".join(f"{k} = :{k}" for k in values)
params = {**values, "id": TEST_LOGIN_ID}
async with db_engine.begin() as conn:
await conn.execute(text(f"UPDATE supplier.supplier_users SET {sets} WHERE id = :id"), params)
async def _login(client):
return await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
# ---- 생성 -------------------------------------------------------------------
async def test_create_success(client, account_seed):
sid = str(account_seed["supplier_id"])
r = await client.post(
"/v1/auth/create",
json={"supplier_id": sid, "id": "pytest_new", "pw": "newpw1234", "name": "새담당자", "role": 2},
)
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["uid"] > 0
assert body["su_id"]
# 생성된 계정으로 즉시 로그인 가능
r2 = await client.post("/v1/auth/login", json={"id": "pytest_new", "pw": "newpw1234"})
lb = r2.json()
assert lb["result"]["success"] is True
assert lb["role"] == 2 # 매니저로 생성됨
# 2) 로그인 -> 토큰 발급
r = await client.post("/v1/auth/login", json={"id": "user1", "pw": "pw1234"})
async def test_create_duplicate(client, account_seed):
sid = str(account_seed["supplier_id"])
payload = {"supplier_id": sid, "id": "pytest_dup", "pw": "x12345"}
r1 = await client.post("/v1/auth/create", json=payload)
assert r1.json()["result"]["success"] is True
r2 = await client.post("/v1/auth/create", json=payload)
assert r2.json()["result"]["code"] == 1201 # ACCOUNT_ALREADY_EXIST
async def test_create_invalid_supplier(client, account_seed):
# 존재하지 않는 supplier_id (no-FK 라 앱에서 검증)
r = await client.post(
"/v1/auth/create",
json={"supplier_id": str(uuid.uuid4()), "id": "pytest_orphan", "pw": "x12345"},
)
body = r.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 101 # INVALID_REQUEST_DATA
async def test_create_malformed_supplier_id(client, account_seed):
r = await client.post(
"/v1/auth/create",
json={"supplier_id": "not-a-uuid", "id": "pytest_bad", "pw": "x12345"},
)
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
# ---- 로그인 -----------------------------------------------------------------
async def test_login_success(client, account_seed):
r = await _login(client)
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["access_token"]
assert body["refresh_token"]
assert body["nickname"] == "닉네임"
access_token = body["access_token"]
# 3) 보호된 엔드포인트 호출
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"})
assert r.status_code == 200
assert r.json()["id"] == "user1"
assert body["name"] == TEST_USER_NAME # 유저 개인 이름
assert body["supplier_name"] == TEST_SUPPLIER_NAME # 공급사명(partner.suppliers)
assert body["role"] == 1
assert body["su_id"]
assert body["supplier_id"] == str(account_seed["supplier_id"])
async def test_login_with_wrong_password(client):
await client.post("/v1/auth/create", json={"id": "user2", "pw": "correct", "nickname": "n"})
r = await client.post("/v1/auth/login", json={"id": "user2", "pw": "wrong"})
async def test_login_wrong_password(client, account_seed):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": "wrong"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is False
# 자격증명 오류는 ACCOUNT_INVALID_INFO(1200)
assert body["result"]["code"] == 1200
assert body.get("access_token", "") == "" # 실패 시 토큰은 빈 문자열
assert body["result"]["code"] == 1200 # ACCOUNT_INVALID_INFO
assert body.get("access_token", "") == ""
async def test_login_nonexistent_account(client):
r = await client.post("/v1/auth/login", json={"id": "ghost", "pw": "whatever"})
assert r.json()["result"]["success"] is False
async def test_duplicate_account_create(client):
r1 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw1234", "nickname": "n"})
assert r1.json()["result"]["success"] is True
r2 = await client.post("/v1/auth/create", json={"id": "dup", "pw": "pw5678", "nickname": "n2"})
body = r2.json()
async def test_login_nonexistent(client):
r = await client.post("/v1/auth/login", json={"id": "ghost_user", "pw": "whatever"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is False
# ACCOUNT_ALREADY_EXIST(1201)
assert body["result"]["code"] == 1201
assert body["result"]["code"] == 1200
async def test_me_without_token_is_rejected(client):
async def _stored_tokens(db_engine, su_id):
"""su_id 의 저장된 토큰을 {type: jwt} dict 로 반환."""
import json
async with db_engine.begin() as conn:
rows = (
await conn.execute(
text("SELECT type, token FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
{"sid": uuid.UUID(su_id)},
)
).fetchall()
out = {}
for t, tok in rows:
out[t] = (json.loads(tok) if isinstance(tok, str) else tok)["jwt"]
return out
async def test_login_stores_access_and_refresh(client, account_seed, db_engine):
# 로그인 시 access(type=1) + refresh(type=2) 2행이 저장되고, 응답 토큰과 일치한다.
body = (await _login(client)).json()
assert body["result"]["success"] is True
stored = await _stored_tokens(db_engine, body["su_id"])
assert set(stored.keys()) == {1, 2} # ACCESS, REFRESH
assert stored[1] == body["access_token"]
assert stored[2] == body["refresh_token"]
async def test_relogin_replaces_tokens_single_session(client, account_seed, db_engine):
# 단일 세션: 재로그인해도 토큰 행이 누적되지 않고 항상 정확히 2행(access/refresh)만 유지된다.
await _login(client)
second = (await _login(client)).json()
su_id = second["su_id"]
async with db_engine.begin() as conn:
count = (
await conn.execute(
text("SELECT count(*) FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
{"sid": uuid.UUID(su_id)},
)
).scalar()
assert count == 2 # 누적되지 않음 (2번 로그인해도 2행)
stored = await _stored_tokens(db_engine, su_id)
assert stored[2] == second["refresh_token"] # 최신 로그인 토큰으로 교체됨
async def test_login_inactive(client, account_seed, db_engine):
await _set_account(db_engine, status=2) # 비활성
r = await _login(client)
body = r.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 1202 # ACCOUNT_BLOCKED_USER
# ---- /me (보호된 엔드포인트) -------------------------------------------------
async def test_me_active(client, account_seed):
access = (await _login(client)).json()["access_token"]
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["id"] == TEST_LOGIN_ID
assert body["supplier_name"] == TEST_SUPPLIER_NAME
async def test_me_inactive_after_token(client, account_seed, db_engine):
# 토큰 발급 후 계정이 비활성(status=2)되면 만료 전이라도 차단된다 (200 + result code).
access = (await _login(client)).json()["access_token"]
await _set_account(db_engine, status=2)
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["result"]["code"] == 1202 # ACCOUNT_BLOCKED_USER
async def test_me_deleted_after_token(client, account_seed, db_engine):
# 소프트 삭제(deleted=TRUE)된 계정도 차단된다.
access = (await _login(client)).json()["access_token"]
await _set_account(db_engine, deleted=True)
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["result"]["code"] == 1200 # ACCOUNT_INVALID_INFO (조회 안 됨)
async def test_me_without_token(client):
r = await client.get("/v1/auth/me")
assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부
async def test_me_invalid_token(client):
r = await client.get("/v1/auth/me", headers={"Authorization": "Bearer garbage.token.value"})
assert r.status_code == 433 # HTTP_INVALID_CLIENT_ACCESS (validator 가 raise)
# ---- refresh ----------------------------------------------------------------
async def test_refresh_success(client, account_seed):
refresh = (await _login(client)).json()["refresh_token"]
r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["access_token"]
async def test_refresh_inactive_after_token(client, account_seed, db_engine):
# 삭제/비활성 계정에는 토큰을 재발급하지 않는다.
refresh = (await _login(client)).json()["refresh_token"]
await _set_account(db_engine, status=2)
r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 1202
assert body.get("access_token", "") == ""
# ---- 로그아웃 / stateful 토큰 검증 -------------------------------------------
async def test_logout_revokes_tokens(client, account_seed, db_engine):
body = (await _login(client)).json()
su_id, access, refresh = body["su_id"], body["access_token"], body["refresh_token"]
# 로그아웃 성공
r = await client.post("/v1/auth/logout", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["result"]["success"] is True
# 저장 토큰이 모두 삭제됨
async with db_engine.begin() as conn:
count = (
await conn.execute(
text("SELECT count(*) FROM supplier.supplier_user_tokens WHERE su_id = :sid AND deleted = false"),
{"sid": uuid.UUID(su_id)},
)
).scalar()
assert count == 0
# 로그아웃 후 같은 access 로 /me → TOKEN_REVOKED(1203)
r2 = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r2.status_code == 200
assert r2.json()["result"]["code"] == 1203
# 로그아웃 후 같은 refresh 로 재발급 → TOKEN_REVOKED(1203)
r3 = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh}"})
assert r3.json()["result"]["code"] == 1203
async def test_logout_without_token(client):
r = await client.post("/v1/auth/logout")
assert r.status_code in (401, 403)
async def test_relogin_invalidates_previous_access(client, account_seed):
# 단일 세션: 재로그인하면 이전 세션의 access 가 무효화된다(저장 토큰이 교체됨).
import asyncio
first = (await _login(client)).json()
await asyncio.sleep(1.1) # exp(초 단위)가 달라져 토큰이 실제로 바뀌도록
second = (await _login(client)).json()
assert first["access_token"] != second["access_token"]
# 이전 access → 무효(TOKEN_REVOKED)
r_old = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {first['access_token']}"})
assert r_old.json()["result"]["code"] == 1203
# 새 access → 정상
r_new = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {second['access_token']}"})
assert r_new.json()["result"]["success"] is True

235
backend/tests/test_chat.py Normal file
View File

@ -0,0 +1,235 @@
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
agent 는 config.use_mock=true 로 내장 MockAgentClient 를 쓴다(결정론적 플로우).
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-"
@pytest_asyncio.fixture
async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# (code, session.status, qt_type, 마감까지 h, quotation.status, 소속 공급사)
specs = [
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
]
sids, qids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text(
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '채팅담당자', now(), 1, 1)"
),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], qids[code] = session_id, qt_id
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, model_name, manufacturer, moq, spec) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000, :model, '테스트제조사', '10', '규격A')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, memo) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs), '메모')"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now() + make_interval(hours => 2))"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
def _h(token):
return {"Authorization": f"Bearer {token}"}
async def _init(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/init", headers=_h(token))
async def _messages(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
async def _send(client, token, sid, user_input, user_input_type=None):
body = {"user_input": user_input, "user_input_type": user_input_type}
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
async def _session_status(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _session_bid(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT bid_price FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _session_reject(db_engine, session_id):
async with db_engine.begin() as conn:
r = (await conn.execute(text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).first()
return r[0], r[1]
# ---- init -------------------------------------------------------------------
async def test_chat_init_returns_meta(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert body["session_status"] == 2
assert body["item_name"] == "상품 P" and body["item_price"] == 100000
assert body["item_maker_name"] == "테스트제조사"
assert body["quotation_end_time"] # 타이머용 마감 시각
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
token = await _login_token(client)
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
# ---- messages (오프닝 seed) -------------------------------------------------
async def test_messages_seeds_opening(client, chat_seed):
token = await _login_token(client)
body = (await _messages(client, token, chat_seed["sids"]["P"])).json()
assert body["result"]["success"] is True
assert len(body["items"]) == 1
msg = body["items"][0]
assert msg["sender"] == 1 # ChatSender.BOT (봇)
assert msg["next_input_mode"] == "confirm"
assert msg["script"]
# ---- send (협상 진행 → 종료) ------------------------------------------------
async def test_send_flow_to_completion(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(턴0) seed
r1 = (await _send(client, token, sid, "네, 시작할게요")).json()
assert r1["result"]["success"] is True
assert r1["message"]["next_input_mode"] == "confirm" # 품목안내
assert r1["session_status"] == 2
r2 = (await _send(client, token, sid, "가격 협상 진행")).json()
assert r2["message"]["next_input_mode"] == "price" # 가격입력 요청
r3 = (await _send(client, token, sid, "90000", user_input_type="price")).json()
assert r3["result"]["success"] is True
assert r3["message"]["chat_end"] is True
assert r3["session_status"] == 3 # 협상완료(DONE)
assert await _session_status(db_engine, sid) == 3
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid)
# 목표가 100000 → 허용 [30000, 170000]. 10 은 하한 미만.
body = (await _send(client, token, sid, "10", user_input_type="price")).json()
assert body["result"]["code"] == 1401 # CHAT_PRICE_OUT_OF_RANGE
async def test_send_not_in_progress(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["C"] # 협상생성(미참여 전 단계)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
async def test_send_requires_auth(client, chat_seed):
sid = chat_seed["sids"]["P"]
r = await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", json={"user_input": "네"})
assert r.status_code in (401, 403)
# ---- 보완: 거부 저장 / 동시전송 가드 / init 만료 정리 ------------------------
async def test_send_rejection_persists_reason(client, chat_seed, db_engine):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝
body = (await _send(client, token, sid, "협상 포기합니다")).json()
assert body["result"]["success"] is True
assert body["message"]["chat_end"] is True
assert body["session_status"] == 5 # 협상거부(REJECTED)
status, reason = await _session_reject(db_engine, sid)
assert status == 5 and reason == "협상 포기합니다" # 거부 사유 저장
async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine):
"""직전 메시지가 USER(이전 턴 처리 중)면 중복 전송을 거절한다 → CHAT_IN_PROGRESS."""
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝(seq=1, BOT)
# 봇 응답이 아직 안 온 상태를 모사: USER 메시지를 마지막(seq=2)으로 직접 삽입
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 2, 2, 0)"),
{"sid": sid},
)
body = (await _send(client, token, sid, "네")).json()
assert body["result"]["code"] == 1403 # CHAT_IN_PROGRESS
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
token = await _login_token(client)
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
body = (await _init(client, token, sid)).json()
assert body["result"]["success"] is True
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨

View File

@ -0,0 +1,211 @@
"""협상 도메인 e2e 테스트 (세션 목록 + 참여).
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
목록의 qt_end_time 은 quotation.end_time 기준이라 세션마다 견적을 함께 시드한다.
"""
import uuid
import bcrypt
import pytest_asyncio
from sqlalchemy import text
TEST_LOGIN_ID = "pytest_nego_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트협상공급사"
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
@pytest_asyncio.fixture
async def nego_seed(db_engine):
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
supplier_id = uuid.uuid4()
other_supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# (code, session.status, qt_type, 마감까지 시간(h), quotation.status, 소속 공급사)
specs = [
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
]
sids, qids = {}, {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text(
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"
),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code], qids[code] = session_id, qt_id
await conn.execute(
text(
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
),
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
)
await conn.execute(
text(
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
),
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
)
await conn.execute(
text(
"INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
)
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
async def _list(client, token, **params):
return await client.get("/v1/negotiation/sessions", headers={"Authorization": f"Bearer {token}"}, params=params)
async def _participate(client, token, session_id):
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
async def _session_status(db_engine, session_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
async def _quotation_status(db_engine, qt_id):
async with db_engine.begin() as conn:
return (await conn.execute(text("SELECT status FROM quotation.quotations WHERE qt_id = :qid"), {"qid": qt_id})).scalar()
# ---- 목록 -------------------------------------------------------------------
async def test_list_returns_only_own_supplier_sessions(client, nego_seed):
token = await _login_token(client)
body = (await _list(client, token)).json()
assert body["result"]["success"] is True
assert body["total"] == 3 # 본인 공급사 3건만 (타 공급사 X 제외)
one = next(i for i in body["items"] if i["item_code"] == f"{MARK}B")
assert one["session_status"] == 2 and one["qt_type"] == 1
assert one["model_name"] == "MODEL-B" and one["maker_name"] == "테스트제조사"
assert one["session_id"] and one["qt_end_time"]
async def test_list_filter_status(client, nego_seed):
token = await _login_token(client)
body = (await _list(client, token, status=2)).json()
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
async def test_list_filter_qt_type(client, nego_seed):
token = await _login_token(client)
body = (await _list(client, token, qt_type=2)).json()
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"}
async def test_list_order_by_quotation_end_time(client, nego_seed):
token = await _login_token(client)
asc = (await _list(client, token, order="asc")).json()["items"]
desc = (await _list(client, token, order="desc")).json()["items"]
assert asc[0]["item_code"] == f"{MARK}B" # +1h 가 가장 임박
assert desc[0]["item_code"] == f"{MARK}C" # +3h 가 가장 멈
async def test_list_pagination(client, nego_seed):
token = await _login_token(client)
body = (await _list(client, token, page=1, page_size=2)).json()
assert body["total"] == 3 and len(body["items"]) == 2
async def test_list_requires_auth(client):
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
# ---- 참여 -------------------------------------------------------------------
async def test_participate_success(client, nego_seed, db_engine):
token = await _login_token(client)
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
r = await _participate(client, token, sid)
assert r.json()["result"]["success"] is True
assert r.json()["session_id"] == str(sid)
assert await _session_status(db_engine, sid) == 2 # 협상중
assert await _quotation_status(db_engine, qid) == 2 # 견적진행중
async def test_participate_forbidden_other_supplier(client, nego_seed):
token = await _login_token(client)
r = await _participate(client, token, nego_seed["sids"]["X"]) # 타 공급사 세션
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
async def test_participate_not_participable(client, nego_seed, db_engine):
token = await _login_token(client)
sid = nego_seed["sids"]["A"]
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE negotiation.sessions SET status = 4 WHERE session_id = :sid"), {"sid": sid}) # 미참여
r = await _participate(client, token, sid)
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
async def test_participate_quotation_closed(client, nego_seed, db_engine):
token = await _login_token(client)
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"]
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET status = 3 WHERE qt_id = :qid"), {"qid": qid}) # 견적마감
r = await _participate(client, token, sid)
assert r.json()["result"]["code"] == 1302 # NEGO_QUOTATION_CLOSED
async def test_participate_deadline_passed_sets_not_participated(client, nego_seed, db_engine):
token = await _login_token(client)
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
r = await _participate(client, token, sid)
assert r.json()["result"]["code"] == 1303 # NEGO_DEADLINE_PASSED
assert await _session_status(db_engine, sid) == 4 # 협상생성이었으므로 미참여로 정리됨
assert await _quotation_status(db_engine, qid) == 1 # 견적은 변경 안 됨
async def test_participate_in_progress_no_state_change(client, nego_seed, db_engine):
token = await _login_token(client)
sid, qid = nego_seed["sids"]["B"], nego_seed["qids"]["B"] # 이미 협상중
r = await _participate(client, token, sid)
assert r.json()["result"]["success"] is True
assert r.json()["session_id"] == str(sid)
assert await _session_status(db_engine, sid) == 2 # 무변경 (협상중 유지)
assert await _quotation_status(db_engine, qid) == 2 # 무변경
async def test_participate_session_not_found(client, nego_seed):
token = await _login_token(client)
r = await _participate(client, token, str(uuid.uuid4()))
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND

View File

@ -1,16 +0,0 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
import LoginPage from '@/pages/LoginPage'
import ListPage from '@/pages/ListPage'
import ChatPage from '@/pages/ChatPage'
const router = createBrowserRouter([
{ path: '/', element: <LoginPage /> },
{ path: '/list', element: <ListPage /> },
{ path: '/chat', element: <ChatPage /> },
])
function App() {
return <RouterProvider router={router} />
}
export default App

View File

@ -1,13 +0,0 @@
import { useMutation } from '@tanstack/react-query'
export interface LoginParams {
id: string
password: string
}
// 임시 stub (검증만 통과하면 성공). TODO: 로그인 API 연동
export function useLoginMutation() {
return useMutation<void, Error, LoginParams>({
mutationFn: async () => {},
})
}

View File

@ -1,14 +0,0 @@
import { useChatInit } from '@/features/chat/hooks/useChatInit'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
// 콘텐츠 영역: 채팅 + 우측 메뉴. mock 데이터를 스토어에 적재한다.
export function ChatContainer() {
useChatInit()
return (
<div className="flex flex-1 min-h-0 w-full">
<ChatSection />
<MenuSection />
</div>
)
}

View File

@ -1,16 +0,0 @@
import { useEffect } from 'react'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import { MOCK_CHAT_INIT } from '@/features/chat/mocks/mockChatInit'
import { MOCK_MESSAGES } from '@/features/chat/mocks/mockMessages'
// mock 세션/대화 데이터를 스토어에 적재 (추후 API 조회로 교체)
export function useChatInit() {
const setInitData = useChatInitStore((s) => s.setInitData)
const setMessages = useChatStore((s) => s.setMessages)
useEffect(() => {
setInitData(MOCK_CHAT_INIT)
setMessages(MOCK_MESSAGES)
}, [setInitData, setMessages])
}

View File

@ -1,25 +0,0 @@
import type { ChatInitData } from '@/features/chat/types'
// 마감까지 카운트다운이 보이도록 현재 시각 기준 미래로 설정
const END_TIME = new Date(Date.now() + 95 * 60 * 1000).toISOString()
// 임시 세션/상품 데이터 (API 연동 전)
export const MOCK_CHAT_INIT: ChatInitData = {
session_id: 's-001',
item_id: 'item-001',
quotation_id: 'qt-001',
item_name: '사무용 노트북 14인치',
item_code: 'IMK-10231',
item_image: '',
item_price: 1350000,
item_model_name: 'NB-1400-PRO',
item_maker_name: '삼성전자',
item_vat_yn: 'VAT별도',
item_delivery_fee_yn: 'N',
item_min_order_quantity: '10 EA',
item_lead_time: '7일',
item_spec: 'Intel Core i7 / 16GB RAM / 512GB SSD / 14인치 FHD',
quotation_memo:
'납기 엄수 부탁드립니다.\n세금계산서는 월말 일괄 발행합니다.\n상세 사양은 첨부 문서를 확인해주세요.',
quotation_end_time: END_TIME,
}

View File

@ -1,106 +0,0 @@
import type { ChatMessage, ChatSummary } from '@/features/chat/types'
// 전 메시지 템플릿을 한눈에 보기 위한 쇼케이스 목 대화 (실제 협상 흐름 아님)
const SUMMARY: ChatSummary = {
md_name: '김엠디',
item_moq: '10 EA',
md_email: 'md@example.com',
item_code: 'IMK-10231',
item_name: '사무용 노트북 14인치',
item_spec: 'Intel Core i7 / 16GB / 512GB SSD',
item_isVAT: false,
item_maker: '삼성전자',
item_model: 'NB-1400-PRO',
final_price: 1200000,
nego_end_date: '2026년 06월 17일 14시 30분',
supplier_name: '대한상사',
item_lead_time: '7일',
md_phone_number: '02-1234-5678',
nego_start_date: '2026년 06월 17일 14시 00분',
item_display_date: '2026년 06월 10일',
item_delivery_type: '협력사배송',
supplier_manager_name: '이담당',
supplier_manager_email: 'sales@example.com',
delivery_type: '협력사배송',
}
const base = {
bot_chat_type: null,
user_input_type: null,
script: null,
chat_end: false,
next_input_mode: null,
next_input_type: null,
summary: null,
indicator_value: null,
} as const
export const MOCK_MESSAGES: ChatMessage[] = [
{
...base,
chat_id: 'm1',
sender: 'bot',
script:
'안녕하세요, 협상을 시작하겠습니다. 본 협상은 자동으로 진행되며, 안내에 따라 응답해주시면 됩니다.',
step: '서비스안내',
display_step: '서비스안내',
},
{
...base,
chat_id: 'm2',
sender: 'bot',
bot_chat_type: 'indicator',
indicator_value: 62,
script: '현재까지의 협상 성공률은 아래와 같습니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm3',
sender: 'user',
user_input_type: 'price',
script: '1,200,000원',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm4',
sender: 'bot',
bot_chat_type: 'summaryCM',
summary: SUMMARY,
script: '제시해주신 금액으로 투찰 결과를 요약해드립니다.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm5',
sender: 'bot',
bot_chat_type: 'summaryRSP',
summary: SUMMARY,
script: '협상이 완료되었습니다. 최종 결과를 요약해드립니다.',
step: '협상종료',
display_step: '협상종료',
},
{
...base,
chat_id: 'm6',
sender: 'bot',
bot_chat_type: 'rejectCM',
script: '제시 금액이 수용되지 않았습니다. 최종 공급 희망 가격과 배송 형태를 입력해주세요.',
step: '가격협상',
display_step: '가격협상',
},
{
...base,
chat_id: 'm7',
sender: 'bot',
script: '추가로 제시할 가격이 있다면 입력해주세요.',
next_input_mode: 'price',
step: '가격협상',
display_step: '가격협상',
},
]

View File

@ -1,24 +0,0 @@
import { cn, interactive } from '@/lib'
const PILL =
'flex items-center justify-center w-full max-w-[130px] h-[50px] rounded-full py-4 px-6 ' +
'text-lg font-semibold whitespace-nowrap ' +
interactive
export function ActionSection() {
return (
<div className="flex w-full py-[35px] items-center justify-end gap-3">
{/* TODO: 협상 참여 동작 연동 */}
<button type="button" className={cn(PILL, 'bg-primary text-primary-foreground')}>
협상 참여
</button>
{/* TODO: 거부 동작 연동 */}
<button
type="button"
className={cn(PILL, 'bg-background text-primary border border-primary')}
>
거부
</button>
</div>
)
}

View File

@ -1,27 +0,0 @@
import { useList } from '@/features/list/hooks/useList'
import { ActionSection } from '@/features/list/components/ActionSection'
import { TableSection } from '@/features/list/components/TableSection'
import { Pagination } from '@/features/list/components/Pagination'
export function ContentContainer() {
const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
useList()
return (
// 좌우 거터(80px) 일괄 적용
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
<ActionSection />
<TableSection
items={items}
isLoading={isLoading}
selectedId={selectedId ?? undefined}
onItemClick={handleItemClick}
/>
<Pagination
totalPages={totalPages}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</div>
)
}

View File

@ -1,57 +0,0 @@
import { useMemo, useState } from 'react'
import { useListStore } from '@/features/list/stores/useListStore'
import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
import type { ListItem } from '@/features/list/types'
const PAGE_SIZE = 20
// 목데이터 클라이언트 필터 (추후 API 조회로 교체)
export function useList() {
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
useListStore()
const [selectedId, setSelectedId] = useState<string | null>(null)
const filtered = useMemo(() => {
const result = MOCK_ITEMS.filter(
(item) =>
(!selectedType || item.qt_type === selectedType) &&
(!selectedStatus || item.session_status === selectedStatus),
)
if (selectedDeadline) {
const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1
result.sort(
(a, b) =>
(new Date(a.qt_end_time).getTime() - new Date(b.qt_end_time).getTime()) * dir,
)
}
return result
}, [selectedType, selectedStatus, selectedDeadline])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const items = useMemo(
() => filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[filtered, currentPage],
)
const selectedItem = useMemo(
() => items.find((item) => item.session_id === selectedId) ?? null,
[items, selectedId],
)
const handleItemClick = (item: ListItem) =>
setSelectedId((prev) => (prev === item.session_id ? null : item.session_id))
return {
items,
isLoading: false,
totalPages,
currentPage,
setCurrentPage,
selectedId,
selectedItem,
handleItemClick,
}
}

View File

@ -1,8 +0,0 @@
// 'YYYY-MM-DD HH:mm'. 빈 값 '-', 파싱 실패 시 원본.
export function formatDateTime(value: string): string {
if (!value) return '-'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}

View File

@ -1,115 +0,0 @@
import type { ListItem } from '@/features/list/types'
// 임시 목데이터 (API 연동 전)
export const MOCK_ITEMS: ListItem[] = [
{
session_id: 's-001',
session_status: '협상생성',
qt_type: '재견적',
qt_number: 'QT-2026-000101',
qt_end_time: '2026-06-18T18:00:00',
item_code: 'IMK-10231',
item_name: '사무용 노트북 14인치',
model_name: 'NB-1400-PRO',
maker_name: '삼성전자',
},
{
session_id: 's-002',
session_status: '협상중',
qt_type: '재협상',
qt_number: 'QT-2026-000102',
qt_end_time: '2026-06-17T12:30:00',
item_code: 'IMK-10232',
item_name: '레이저 복합기',
model_name: 'MFC-7890DW',
maker_name: '브라더',
},
{
session_id: 's-003',
session_status: '협상완료',
qt_type: '재견적',
qt_number: 'QT-2026-000103',
qt_end_time: '2026-06-20T09:00:00',
item_code: 'IMK-10233',
item_name: '27인치 4K 모니터',
model_name: 'U2723QE',
maker_name: '델',
},
{
session_id: 's-004',
session_status: '협상거부',
qt_type: '재협상',
qt_number: 'QT-2026-000104',
qt_end_time: '2026-06-19T15:45:00',
item_code: 'IMK-10234',
item_name: '무선 기계식 키보드',
model_name: 'MX-KEYS-M',
maker_name: '로지텍',
},
{
session_id: 's-005',
session_status: '미참여',
qt_type: '재견적',
qt_number: 'QT-2026-000105',
qt_end_time: '2026-06-22T11:00:00',
item_code: 'IMK-10235',
item_name: 'A4 무선 레이저프린터',
model_name: 'SL-M2030',
maker_name: 'HP',
},
{
session_id: 's-006',
session_status: '협상중',
qt_type: '재견적',
qt_number: 'QT-2026-000106',
qt_end_time: '2026-06-16T20:00:00',
item_code: 'IMK-10236',
item_name: '회의실 대형 디스플레이 65인치',
model_name: 'QM65R',
maker_name: '삼성전자',
},
{
session_id: 's-007',
session_status: '협상생성',
qt_type: '재협상',
qt_number: 'QT-2026-000107',
qt_end_time: '2026-06-25T17:00:00',
item_code: 'IMK-10237',
item_name: '인체공학 사무용 의자',
model_name: 'ERGO-700',
maker_name: '시디즈',
},
{
session_id: 's-008',
session_status: '협상완료',
qt_type: '재견적',
qt_number: 'QT-2026-000108',
qt_end_time: '2026-06-21T10:30:00',
item_code: 'IMK-10238',
item_name: '네트워크 스위치 24포트',
model_name: 'SG350-28',
maker_name: '시스코',
},
{
session_id: 's-009',
session_status: '미참여',
qt_type: '재협상',
qt_number: 'QT-2026-000109',
qt_end_time: '2026-06-23T14:00:00',
item_code: 'IMK-10239',
item_name: '외장 SSD 2TB',
model_name: 'T7-Shield-2T',
maker_name: '삼성전자',
},
{
session_id: 's-010',
session_status: '협상중',
qt_type: '재견적',
qt_number: 'QT-2026-000110',
qt_end_time: '2026-06-24T16:20:00',
item_code: 'IMK-10240',
item_name: '화상회의용 웹캠',
model_name: 'BRIO-4K',
maker_name: '로지텍',
},
]

View File

@ -17,6 +17,7 @@
"slate": "^0.124.1",
"slate-history": "^0.113.1",
"slate-react": "^0.124.2",
"sonner": "^2.0.7",
"zustand": "^5.0.14"
},
"devDependencies": {
@ -3260,6 +3261,16 @@
"slate-dom": ">=0.119.1"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@ -22,6 +22,7 @@
"slate": "^0.124.1",
"slate-history": "^0.113.1",
"slate-react": "^0.124.2",
"sonner": "^2.0.7",
"zustand": "^5.0.14"
},
"devDependencies": {

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

37
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,37 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
import { setUnauthorizedHandler } from '@/apis'
import { RequireAuth } from '@/features/auth'
import LoginPage from '@/pages/LoginPage'
import ListPage from '@/pages/ListPage'
import ChatPage from '@/pages/ChatPage'
const router = createBrowserRouter([
{ path: '/', element: <LoginPage /> },
{
path: '/list',
element: (
<RequireAuth>
<ListPage />
</RequireAuth>
),
},
{
path: '/chat',
element: (
<RequireAuth>
<ChatPage />
</RequireAuth>
),
},
])
// 토큰 만료/폐기로 인증이 끊기면 로그인 페이지로 이동시킨다.
setUnauthorizedHandler(() => {
void router.navigate('/')
})
function App() {
return <RouterProvider router={router} />
}
export default App

View File

@ -0,0 +1,37 @@
// 인증 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
// refresh_token 재발급은 http.ts 인터셉터가 자동 처리하므로 여기서 노출하지 않는다.
import { http } from '@/apis/http'
import type {
CreateAccountRequest,
CreateAccountResponse,
LoginRequest,
LoginResponse,
LogoutResponse,
MeResponse,
} from './auth.type'
export const authApi = {
/** POST /v1/auth/login — ID/PW 로 로그인, access/refresh 토큰 발급 */
login: async (body: LoginRequest): Promise<LoginResponse> => {
const res = await http.post<LoginResponse>('/v1/auth/login', body)
return res.data
},
/** POST /v1/auth/create — 신규 공급사 유저 계정 생성 */
createAccount: async (body: CreateAccountRequest): Promise<CreateAccountResponse> => {
const res = await http.post<CreateAccountResponse>('/v1/auth/create', body)
return res.data
},
/** GET /v1/auth/me — 현재 로그인 유저 정보 (access token 필요) */
me: async (): Promise<MeResponse> => {
const res = await http.get<MeResponse>('/v1/auth/me')
return res.data
},
/** POST /v1/auth/logout — 서버측 토큰 폐기(단일 세션) */
logout: async (): Promise<LogoutResponse> => {
const res = await http.post<LogoutResponse>('/v1/auth/logout')
return res.data
},
}

View File

@ -0,0 +1,5 @@
// 인증 도메인의 TanStack Query 키 팩토리.
export const authKeys = {
all: ['auth'] as const,
me: () => [...authKeys.all, 'me'] as const,
}

View File

@ -0,0 +1,55 @@
// 인증 도메인의 변경(useMutation) 훅.
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { tokenStorage } from '@/apis/tokenStorage'
import { authApi } from './auth.api'
import { authKeys } from './auth.keys'
import type { CreateAccountRequest, LoginResponse } from './auth.type'
/** 로그인 폼이 다루는 파라미터 (UI 친화적인 camelCase) */
export interface LoginParams {
id: string
password: string
}
/**
* 로그인: 성공 시 토큰을 저장하고 me 캐시를 무효화한다.
*/
export function useLoginMutation() {
const queryClient = useQueryClient()
return useMutation<LoginResponse, Error, LoginParams>({
mutationFn: async ({ id, password }) => {
const data = await authApi.login({ id, pw: password })
tokenStorage.setTokens(data.access_token, data.refresh_token)
return data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: authKeys.me() })
},
})
}
/**
* 로그아웃: 서버 토큰 폐기를 시도하고(실패해도) 로컬 토큰/캐시를 비운다.
*/
export function useLogoutMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, void>({
mutationFn: async () => {
try {
await authApi.logout()
} finally {
tokenStorage.clear()
}
},
onSettled: () => {
queryClient.clear()
},
})
}
/** 공급사 유저 계정 생성 */
export function useCreateAccountMutation() {
return useMutation({
mutationFn: (body: CreateAccountRequest) => authApi.createAccount(body),
})
}

View File

@ -0,0 +1,20 @@
// 인증 도메인의 조회(useQuery) 훅.
import { useQuery } from '@tanstack/react-query'
import { tokenStorage } from '@/apis/tokenStorage'
import { authApi } from './auth.api'
import { authKeys } from './auth.keys'
import { toAuthUser } from './auth.type'
/**
* 현재 로그인 유저 정보 조회.
* 토큰이 있을 때만 활성화되며, AuthUser(카멜케이스)로 가공해 반환한다.
*/
export function useMeQuery() {
return useQuery({
queryKey: authKeys.me(),
queryFn: authApi.me,
enabled: tokenStorage.hasToken(),
staleTime: 5 * 60 * 1000, // 5분
select: toAuthUser,
})
}

View File

@ -0,0 +1,91 @@
// 인증 API 의 요청/응답 타입.
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
import type { ApiResult } from '@/apis/types'
/** 유저 권한 (supplier_users.role) */
export const UserRole = {
USER: 1,
MANAGER: 2,
} as const
export type UserRole = (typeof UserRole)[keyof typeof UserRole]
export const USER_ROLE_LABEL: Record<UserRole, string> = {
[UserRole.USER]: '일반',
[UserRole.MANAGER]: '매니저',
}
// --- 로그인 ---------------------------------------------------------------
export interface LoginRequest {
id: string
pw: string
}
export interface LoginResponse {
result: ApiResult
su_id: string
name: string
supplier_id: string
supplier_name: string
role: number
access_token: string
refresh_token: string
}
// --- 계정 생성 ------------------------------------------------------------
export interface CreateAccountRequest {
supplier_id: string
id: string
pw: string
name?: string
email?: string
contact_number?: string
role?: number
}
export interface CreateAccountResponse {
result: ApiResult
su_id: string
}
// --- 토큰 재발급 ----------------------------------------------------------
export interface RefreshTokenResponse {
result: ApiResult
access_token: string
}
// --- 내 정보 (GET /v1/auth/me) -------------------------------------------
export interface MeResponse {
result: ApiResult
su_id: string
id: string
name: string
supplier_id: string
supplier_name: string
role: number
}
// --- 로그아웃 -------------------------------------------------------------
export interface LogoutResponse {
result: ApiResult
}
/** 앱에서 다루기 편한 현재 유저 형태 (MeResponse 에서 파생) */
export interface AuthUser {
suId: string
loginId: string
name: string
supplierId: string
supplierName: string
role: number
}
export function toAuthUser(res: MeResponse): AuthUser {
return {
suId: res.su_id,
loginId: res.id,
name: res.name,
supplierId: res.supplier_id,
supplierName: res.supplier_name,
role: res.role,
}
}

View File

@ -0,0 +1,11 @@
// 인증 API 모듈 공개 표면.
export { authApi } from './auth.api'
export { authKeys } from './auth.keys'
export { useMeQuery } from './auth.queries'
export {
useLoginMutation,
useLogoutMutation,
useCreateAccountMutation,
type LoginParams,
} from './auth.mutations'
export * from './auth.type'

View File

@ -0,0 +1,33 @@
// 채팅 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
import { http } from '@/apis/http'
import type {
ChatInitResponse,
ChatMessagesResponse,
ChatSendRequest,
ChatSendResponse,
} from './chat.type'
export const chatApi = {
/** GET .../chat/init — 상품·견적 메타 + 세션 상태 + 마감 시각 */
getInit: async (sessionId: string): Promise<ChatInitResponse> => {
const res = await http.get<ChatInitResponse>(`/v1/negotiation/sessions/${sessionId}/chat/init`)
return res.data
},
/** GET .../chat/messages — 대화 히스토리(seq 오름차순). 비어 있으면 오프닝 포함 */
getMessages: async (sessionId: string): Promise<ChatMessagesResponse> => {
const res = await http.get<ChatMessagesResponse>(
`/v1/negotiation/sessions/${sessionId}/chat/messages`,
)
return res.data
},
/** POST .../chat/send — 한 턴 전송, 새 봇 메시지 1건 반환(append-only) */
send: async (sessionId: string, body: ChatSendRequest): Promise<ChatSendResponse> => {
const res = await http.post<ChatSendResponse>(
`/v1/negotiation/sessions/${sessionId}/chat/send`,
body,
)
return res.data
},
}

View File

@ -0,0 +1,6 @@
// 채팅 도메인의 TanStack Query 키 팩토리.
export const chatKeys = {
all: ['chat'] as const,
init: (sessionId: string) => [...chatKeys.all, 'init', sessionId] as const,
messages: (sessionId: string) => [...chatKeys.all, 'messages', sessionId] as const,
}

View File

@ -0,0 +1,14 @@
// 채팅 도메인의 변경(useMutation) 훅.
import { useMutation } from '@tanstack/react-query'
import { chatApi } from './chat.api'
import type { ChatSendRequest } from './chat.type'
/**
* 협상 한 턴 전송. append-only 라 캐시 무효화/refetch 를 하지 않는다.
* 응답의 새 봇 메시지는 호출부(컨트롤러)가 스토어에 직접 append 한다.
*/
export function useChatSendMutation(sessionId: string) {
return useMutation({
mutationFn: (body: ChatSendRequest) => chatApi.send(sessionId, body),
})
}

View File

@ -0,0 +1,29 @@
// 채팅 도메인의 조회(useQuery) 훅.
import { useQuery } from '@tanstack/react-query'
import { chatApi } from './chat.api'
import { chatKeys } from './chat.keys'
import { mapInit, mapMessage } from './chat.type'
/** 채팅 진입 메타(상품·견적). 마감 시각은 거의 불변이라 오래 캐싱한다. */
export function useChatInitQuery(sessionId: string) {
return useQuery({
queryKey: chatKeys.init(sessionId),
queryFn: () => chatApi.getInit(sessionId).then(mapInit),
enabled: !!sessionId,
staleTime: 5 * 60 * 1000,
})
}
/**
* 대화 히스토리. 진입 시 1회만 받고 이후엔 send 응답을 로컬에 append 한다(append-only).
* 따라서 백그라운드 refetch 가 로컬 상태를 덮지 않도록 staleTime 을 무한대로 둔다.
*/
export function useChatMessagesQuery(sessionId: string) {
return useQuery({
queryKey: chatKeys.messages(sessionId),
queryFn: () => chatApi.getMessages(sessionId).then((r) => r.items.map(mapMessage)),
enabled: !!sessionId,
staleTime: Infinity,
refetchOnWindowFocus: false,
})
}

View File

@ -0,0 +1,103 @@
// 채팅 API 의 와이어 타입(백엔드 snake_case 미러) + feature 타입 매퍼.
// sender 는 정수 코드(1=BOT, 2=USER)로 내려오고 프론트에서 'bot'|'user' 로 매핑한다.
import type { ApiResult } from '@/apis/types'
import type {
ChatInitData,
ChatMessage,
NextInputMode,
UserInputType,
} from '@/features/chat/types'
// RemoveNoneResponse 로 null 필드는 생략될 수 있어 대부분 optional.
export interface ChatMessageWire {
chat_id: string
session_id: string
seq: number
sender: number
script?: string
user_input_type?: string | null
step?: string
display_step?: string
next_input_mode?: string | null
next_input_type?: string[] | null
chat_end?: boolean
indicator_value?: number | null
bot_chat_type?: string | null
}
export interface ChatInitResponse {
result: ApiResult
session_id: string
session_status: number
quotation_id: string
quotation_end_time: string
quotation_memo?: string
item_id: string
item_name: string
item_code?: string
item_image?: string
item_price: number
item_model_name?: string
item_maker_name?: string
item_spec?: string
item_lead_time?: string
item_min_order_quantity?: string
item_vat_yn?: boolean
item_delivery_fee_yn?: boolean
}
export interface ChatMessagesResponse {
result: ApiResult
items: ChatMessageWire[]
}
export interface ChatSendRequest {
user_input: string
user_input_type?: string | null
}
export interface ChatSendResponse {
result: ApiResult
message?: ChatMessageWire
session_status: number
}
// --- 매퍼: 와이어 → feature 타입 --------------------------------------------
export function mapMessage(w: ChatMessageWire): ChatMessage {
return {
chat_id: w.chat_id,
sender: w.sender === 1 ? 'bot' : 'user',
bot_chat_type: (w.bot_chat_type ?? null) as ChatMessage['bot_chat_type'],
user_input_type: (w.user_input_type ?? null) as UserInputType | null,
script: w.script ?? null,
chat_end: w.chat_end ?? false,
next_input_mode: (w.next_input_mode ?? null) as NextInputMode | null,
next_input_type: w.next_input_type ?? null,
step: w.step ?? '',
display_step: w.display_step ?? '',
summary: null, // (범위 외) 최종 요약 카드는 추후
indicator_value: w.indicator_value ?? null,
}
}
export function mapInit(r: ChatInitResponse): ChatInitData {
return {
session_id: r.session_id,
item_id: r.item_id,
quotation_id: r.quotation_id,
item_name: r.item_name,
item_code: r.item_code ?? '',
item_image: r.item_image ?? '',
item_price: r.item_price ?? 0,
item_model_name: r.item_model_name ?? '',
item_maker_name: r.item_maker_name ?? '',
item_vat_yn: r.item_vat_yn == null ? '' : r.item_vat_yn ? 'VAT포함' : 'VAT별도',
item_delivery_fee_yn:
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
item_min_order_quantity: r.item_min_order_quantity ?? '',
item_lead_time: r.item_lead_time ?? '',
item_spec: r.item_spec ?? '',
quotation_memo: r.quotation_memo ?? '',
quotation_end_time: r.quotation_end_time ?? '',
}
}

View File

@ -0,0 +1,13 @@
// 채팅 도메인 API 공개 표면.
export { chatApi } from './chat.api'
export { chatKeys } from './chat.keys'
export { useChatInitQuery, useChatMessagesQuery } from './chat.queries'
export { useChatSendMutation } from './chat.mutations'
export { mapInit, mapMessage } from './chat.type'
export type {
ChatInitResponse,
ChatMessagesResponse,
ChatMessageWire,
ChatSendRequest,
ChatSendResponse,
} from './chat.type'

118
frontend/src/apis/http.ts Normal file
View File

@ -0,0 +1,118 @@
// 공용 axios 인스턴스.
// - 요청 시 access token 을 Authorization 헤더에 주입
// - 응답의 result.success=false 봉투를 ApiError 로 변환
// - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도
// - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { tokenStorage } from './tokenStorage'
import { ApiError, ErrorCode, type ApiResult } from './types'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
export const http = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
})
// 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler
}
function handleUnauthorized(): void {
tokenStorage.clear()
onUnauthorized?.()
}
// --- 요청 인터셉터: access token 주입 ------------------------------------
http.interceptors.request.use((config) => {
const token = tokenStorage.getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ----------
let refreshPromise: Promise<string> | null = null
async function refreshAccessToken(): Promise<string> {
const refreshToken = tokenStorage.getRefreshToken()
if (!refreshToken) {
throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN')
}
// 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다.
const res = await axios.post<{ result: ApiResult; access_token?: string }>(
`${BASE_URL}/v1/auth/refresh_token`,
null,
{ headers: { Authorization: `Bearer ${refreshToken}` } },
)
const { result, access_token } = res.data
if (!result.success || !access_token) {
throw new ApiError(result.code, result.desc)
}
tokenStorage.setAccessToken(access_token)
return access_token
}
function toApiError(error: unknown): ApiError {
if (error instanceof ApiError) return error
if (axios.isAxiosError(error)) {
const result = (error.response?.data as { result?: ApiResult } | undefined)?.result
if (result) return new ApiError(result.code, result.desc, error.message)
// 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다
const detail = (error.response?.data as { detail?: string } | undefined)?.detail
const status = error.response?.status ?? 0
return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message)
}
return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error))
}
// --- 응답 인터셉터 -------------------------------------------------------
http.interceptors.response.use(
(response) => {
// HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환
const result = (response.data as { result?: ApiResult } | undefined)?.result
if (result && !result.success) {
// 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료
if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) {
handleUnauthorized()
}
throw new ApiError(result.code, result.desc)
}
return response
},
async (error: AxiosError) => {
const status = error.response?.status
const original = error.config as
| (InternalAxiosRequestConfig & { _retried?: boolean })
| undefined
const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code
const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false
// access token 만료 → refresh 후 1회 재시도
if (status === 434 && original && !original._retried && !isRefreshCall) {
original._retried = true
try {
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null
})
const newToken = await refreshPromise
original.headers.Authorization = `Bearer ${newToken}`
return http(original)
} catch (refreshError) {
handleUnauthorized()
throw toApiError(refreshError)
}
}
// 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) /
// 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료
const isAuthFailStatus =
status === 401 || status === 403 || status === 433 || status === 435 || status === 436
if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) {
handleUnauthorized()
}
throw toApiError(error)
},
)

View File

@ -0,0 +1,9 @@
// apis 레이어 공개 표면.
export { http, setUnauthorizedHandler } from './http'
export { tokenStorage } from './tokenStorage'
export { ApiError, ErrorCode, isApiError, getApiErrorMessage } from './types'
export type { ApiResult, ApiEnvelope } from './types'
export * from './auth'
export * from './negotiation'
export * from './chat'

View File

@ -0,0 +1,6 @@
// 협상 API 모듈 공개 표면.
export { negotiationApi } from './negotiation.api'
export { negotiationKeys } from './negotiation.keys'
export { useSessionListQuery } from './negotiation.queries'
export { useParticipateMutation, useRejectMutation } from './negotiation.mutations'
export * from './negotiation.type'

View File

@ -0,0 +1,34 @@
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
import { http } from '@/apis/http'
import type {
ParticipateResponse,
RejectRequest,
RejectResponse,
SessionListParams,
SessionListResponse,
} from './negotiation.type'
export const negotiationApi = {
/** GET /v1/negotiation/sessions — 로그인 공급사의 협상 세션 목록(필터/정렬/페이지) */
getSessions: async (params: SessionListParams = {}): Promise<SessionListResponse> => {
const res = await http.get<SessionListResponse>('/v1/negotiation/sessions', { params })
return res.data
},
/** POST /v1/negotiation/sessions/{id}/participate — 협상 세션 참여 */
participate: async (sessionId: string): Promise<ParticipateResponse> => {
const res = await http.post<ParticipateResponse>(
`/v1/negotiation/sessions/${sessionId}/participate`,
)
return res.data
},
/** POST /v1/negotiation/sessions/{id}/reject — 협상 세션 거부 */
reject: async (sessionId: string, body: RejectRequest): Promise<RejectResponse> => {
const res = await http.post<RejectResponse>(
`/v1/negotiation/sessions/${sessionId}/reject`,
body,
)
return res.data
},
}

View File

@ -0,0 +1,8 @@
// 협상 도메인의 TanStack Query 키 팩토리.
import type { SessionListParams } from './negotiation.type'
export const negotiationKeys = {
all: ['negotiation'] as const,
sessions: () => [...negotiationKeys.all, 'sessions'] as const,
sessionList: (params: SessionListParams) => [...negotiationKeys.sessions(), params] as const,
}

View File

@ -0,0 +1,32 @@
// 협상 도메인의 변경(useMutation) 훅.
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys'
import type { RejectRequest } from './negotiation.type'
/**
* 협상 세션 참여: 성공 시 세션 목록 캐시를 무효화해 상태를 갱신한다.
*/
export function useParticipateMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (sessionId: string) => negotiationApi.participate(sessionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}
/**
* 협상 세션 거부: 성공 시 세션 목록 캐시를 무효화한다.
*/
export function useRejectMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, request }: { sessionId: string; request: RejectRequest }) =>
negotiationApi.reject(sessionId, request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}

View File

@ -0,0 +1,17 @@
// 협상 도메인의 조회(useQuery) 훅.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys'
import type { SessionListParams } from './negotiation.type'
/**
* 협상 세션 목록 조회.
* 페이지 전환 시 이전 데이터를 유지해 깜빡임을 줄인다.
*/
export function useSessionListQuery(params: SessionListParams = {}) {
return useQuery({
queryKey: negotiationKeys.sessionList(params),
queryFn: () => negotiationApi.getSessions(params),
placeholderData: keepPreviousData,
})
}

View File

@ -0,0 +1,80 @@
// 협상 API 의 요청/응답 타입 + 코드값 enum.
// 와이어 포맷은 백엔드(snake_case)를 그대로 미러링한다.
import type { ApiResult } from '@/apis/types'
/** 협상 세션 상태 (negotiation.sessions.status) */
export const SessionStatus = {
CREATED: 1, // 협상생성(참여대기)
IN_PROGRESS: 2, // 협상중
DONE: 3, // 협상완료
NOT_PARTICIPATED: 4, // 미참여(마감)
REJECTED: 5, // 협상거부
} as const
export type SessionStatus = (typeof SessionStatus)[keyof typeof SessionStatus]
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
[SessionStatus.CREATED]: '협상생성',
[SessionStatus.IN_PROGRESS]: '협상중',
[SessionStatus.DONE]: '협상완료',
[SessionStatus.NOT_PARTICIPATED]: '미참여',
[SessionStatus.REJECTED]: '협상거부',
}
/** 견적 타입 (negotiation.sessions.qt_type) */
export const QtType = {
RENEGO: 1, // 재협상(1:1)
REQUOTE: 2, // 재견적(1:N)
} as const
export type QtType = (typeof QtType)[keyof typeof QtType]
export const QT_TYPE_LABEL: Record<QtType, string> = {
[QtType.RENEGO]: '재협상',
[QtType.REQUOTE]: '재견적',
}
// --- 세션 목록 (GET /v1/negotiation/sessions) ----------------------------
export interface SessionListParams {
status?: number // SessionStatus 코드 필터
qt_type?: number // QtType 코드 필터
order?: 'asc' | 'desc' // 마감(qt_end_time) 정렬, asc=임박순
page?: number
page_size?: number
}
export interface SessionListItem {
session_id: string
session_status: number
qt_type: number
qt_number: string
qt_end_time: string // ISO 8601 마감 시각
item_code: string
item_name: string
model_name: string
maker_name: string
}
export interface SessionListResponse {
result: ApiResult
items: SessionListItem[]
total: number
page: number
page_size: number
}
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
export interface ParticipateResponse {
result: ApiResult
session_id: string
}
// --- 거부 (POST /v1/negotiation/sessions/{id}/reject) ---------------------
// reject_reason: 프리셋(단종/품절) 라벨 또는 '기타' 직접 입력 텍스트.
// (백엔드 sessions.reject_reason 컬럼에 대응. 엔드포인트는 백엔드 추가 예정)
export interface RejectRequest {
reject_reason: string
}
export interface RejectResponse {
result: ApiResult
session_id: string
}

View File

@ -0,0 +1,27 @@
// JWT access/refresh 토큰의 영속 저장소.
// axios 인터셉터(http.ts)와 인증 mutation 이 공유한다.
const ACCESS_KEY = 'negosium.accessToken'
const REFRESH_KEY = 'negosium.refreshToken'
export const tokenStorage = {
getAccessToken: (): string | null => localStorage.getItem(ACCESS_KEY),
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
setTokens: (accessToken: string, refreshToken: string): void => {
localStorage.setItem(ACCESS_KEY, accessToken)
localStorage.setItem(REFRESH_KEY, refreshToken)
},
/** refresh_token 으로 access_token 만 갱신할 때 사용 */
setAccessToken: (accessToken: string): void => {
localStorage.setItem(ACCESS_KEY, accessToken)
},
clear: (): void => {
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
},
hasToken: (): boolean => localStorage.getItem(ACCESS_KEY) !== null,
}

View File

@ -0,0 +1,88 @@
// 모든 API 응답이 공유하는 공통 봉투(envelope)와 에러 타입.
// 백엔드는 HTTP 200 으로 내려주면서 result.success=false 로 비즈니스 에러를 표현한다.
/** 백엔드가 모든 응답에 공통으로 내려주는 처리 결과 */
export interface ApiResult {
success: boolean
code: number
desc: string
}
/** result 봉투를 포함하는 응답의 베이스 */
export interface ApiEnvelope {
result: ApiResult
}
/** 백엔드 ErrorType 코드 (backend/common 의 ErrorType 과 1:1 매핑) */
export const ErrorCode = {
SUCCESS: 0,
FAIL: 1,
DB_RUN_FAILED: 10,
DB_ALREADY_SAME_KEY: 11,
JSON_PARSE_ERROR: 100,
INVALID_REQUEST_DATA: 101,
INTERNAL_EXCEPTION: 102,
HTTP_INVALID_CLIENT_REQUEST: 419,
HTTP_TO_MANY_REQUEST: 429,
HTTP_INVALID_CLIENT_ACCESS: 433,
HTTP_ACCESS_TOKEN_EXPIRED: 434,
HTTP_REFRESH_TOKEN_EXPIRED: 435,
HTTP_INVALID_TOKEN_ACCESS: 436,
ACCOUNT_INVALID_INFO: 1200,
ACCOUNT_ALREADY_EXIST: 1201,
ACCOUNT_BLOCKED_USER: 1202,
TOKEN_REVOKED: 1203,
NEGO_FORBIDDEN: 1300,
NEGO_NOT_PARTICIPABLE: 1301,
NEGO_QUOTATION_CLOSED: 1302,
NEGO_DEADLINE_PASSED: 1303,
NEGO_NOT_FOUND: 1304,
CHAT_NOT_IN_PROGRESS: 1400,
CHAT_PRICE_OUT_OF_RANGE: 1401,
CHAT_AGENT_UNAVAILABLE: 1402,
CHAT_IN_PROGRESS: 1403,
} as const
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
/** code → 사용자에게 보여줄 한국어 메시지 */
const API_ERROR_MESSAGES: Record<number, string> = {
[ErrorCode.ACCOUNT_INVALID_INFO]: '아이디 또는 비밀번호가 올바르지 않습니다.',
[ErrorCode.ACCOUNT_ALREADY_EXIST]: '이미 존재하는 아이디입니다.',
[ErrorCode.ACCOUNT_BLOCKED_USER]: '비활성화된 계정입니다. 관리자에게 문의하세요.',
[ErrorCode.TOKEN_REVOKED]: '다른 기기에서 로그인되어 세션이 종료되었습니다.',
[ErrorCode.HTTP_ACCESS_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
[ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED]: '로그인이 만료되었습니다. 다시 로그인해주세요.',
[ErrorCode.NEGO_FORBIDDEN]: '해당 협상에 접근할 권한이 없습니다.',
[ErrorCode.NEGO_NOT_PARTICIPABLE]: '참여할 수 없는 협상입니다.',
[ErrorCode.NEGO_QUOTATION_CLOSED]: '마감된 견적입니다.',
[ErrorCode.NEGO_DEADLINE_PASSED]: '협상 마감 시간이 지났습니다.',
[ErrorCode.NEGO_NOT_FOUND]: '협상을 찾을 수 없습니다.',
[ErrorCode.CHAT_NOT_IN_PROGRESS]: '진행 중인 협상이 아닙니다. 목록으로 돌아갑니다.',
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
}
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */
export class ApiError extends Error {
readonly code: number
readonly desc: string
constructor(code: number, desc: string, message?: string) {
super(message ?? API_ERROR_MESSAGES[code] ?? desc)
this.name = 'ApiError'
this.code = code
this.desc = desc
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}
/** code 에 해당하는 사용자 안내 메시지 (없으면 기본 문구) */
export function getApiErrorMessage(error: unknown, fallback = '요청 처리 중 오류가 발생했습니다.'): string {
if (isApiError(error)) return API_ERROR_MESSAGES[error.code] ?? error.message ?? fallback
return fallback
}

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,30 @@
import { type ReactNode, useEffect } from 'react'
export interface ModalProps {
children?: ReactNode
onClose: () => void
}
// 공통 모달: 반투명 배경 + 배경 클릭/ESC 로 닫기.
export function Modal({ children, onClose }: ModalProps) {
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
}
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEscKey)
return () => document.removeEventListener('keydown', handleEscKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.40)]"
onClick={handleBackdropClick}
>
{children}
</div>
)
}

View File

@ -1,5 +1,7 @@
export { Button } from '@/components/Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from '@/components/Button'
export { Input } from '@/components/Input'
export { Modal } from '@/components/Modal'
export type { ModalProps } from '@/components/Modal'
export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'

View File

@ -1,5 +1,6 @@
import { type ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
const queryClient = new QueryClient({
defaultOptions: {
@ -12,5 +13,10 @@ const queryClient = new QueryClient({
})
export function Provider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-center" />
</QueryClientProvider>
)
}

View File

@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router'
import { tokenStorage } from '@/apis'
// 토큰이 없으면 로그인 페이지로 보낸다 (인증 영역 가드).
// 토큰이 있으나 만료/폐기된 경우는 요청 시 인터셉터가 세션을 종료시킨다.
export function RequireAuth({ children }: { children: ReactNode }) {
if (!tokenStorage.hasToken()) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@ -1,18 +1,30 @@
import { useNavigate } from 'react-router'
import { useLogoutMutation, useMeQuery } from '@/apis'
import { Button } from '@/components'
// 사이드바 하단: 공급사명 + 로그아웃
export function SidebarFooter() {
const navigate = useNavigate()
const { data: user } = useMeQuery()
const logout = useLogoutMutation()
const handleLogout = () => {
logout.mutate(undefined, {
onSuccess: () => navigate('/'),
})
}
return (
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
{/* TODO: 공급사명 (auth store 연동) */}
<span className="truncate">-</span>
<span className="truncate">{user?.supplierName ?? '-'}</span>
</div>
<Button
variant="secondary"
size="sm"
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
// TODO: 로그아웃 mutation 연동
onClick={handleLogout}
disabled={logout.isPending}
>
로그아웃
</Button>

View File

@ -0,0 +1,2 @@
// 실제 로그인 API 연동은 apis/auth 로 이전됨. 기존 import 경로 호환을 위해 재노출한다.
export { useLoginMutation, type LoginParams } from '@/apis/auth'

View File

@ -1,2 +1,3 @@
export { LoginForm } from '@/features/auth/components/LoginForm'
export { SidebarFooter } from '@/features/auth/components/SidebarFooter'
export { RequireAuth } from '@/features/auth/components/RequireAuth'

Some files were not shown because too many files have changed in this diff Show More